1use nostr_sdk::prelude::{Event, PublicKey};
7use nostr_sdk::prelude::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::prelude::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::prelude::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.content = super::attachments::strip_attachment_urls(&msg.content, &msg.attachments);
71 msg.wrapper_event_id = Some(opened.wrapper_id.to_hex());
72 msg
73}
74
75pub fn ingest_message(
80 state: &mut ChatState,
81 opened: &OpenedMessage,
82 my_pubkey: &PublicKey,
83) -> Option<Message> {
84 let chat_id = opened.channel_id.to_hex();
85 let msg = build_message(opened, my_pubkey);
86 if crate::db::events::event_exists(&msg.id).unwrap_or(false) {
93 return None;
94 }
95 state.ensure_community_chat(&chat_id);
96 if state.add_message_to_chat(&chat_id, &msg) {
97 Some(msg)
98 } else {
99 None
100 }
101}
102
103pub enum IncomingEvent {
108 NewMessage(Message),
109 Updated { target_id: String, message: Message, edit_event: Option<Box<crate::stored_event::StoredEvent>> },
114 Removed { target_id: String },
115 ReactionRemoved { message_id: String, reaction_id: String, message: Message },
120 Presence { npub: String, joined: bool, event_id: String, created_at: u64, invited_by: Option<String>, invited_label: Option<String> },
127 Kicked { community_id: String },
132 SelfLeft { community_id: String },
138 WebxdcPeer {
143 npub: String,
144 topic_id: String,
145 node_addr: Option<String>,
147 event_id: String,
148 created_at: u64,
149 },
150 Typing { npub: String, until: u64 },
154}
155
156pub fn process_incoming(
162 state: &mut ChatState,
163 event: &Event,
164 channel: &Channel,
165 my_pubkey: &PublicKey,
166) -> Option<IncomingEvent> {
167 let outer_bytes = event.id.to_bytes();
173 if crate::db::events::wrapper_event_exists(&event.id.to_hex()).unwrap_or(false)
174 || crate::db::wrappers::processed_wrapper_exists(&outer_bytes)
175 {
176 return None;
177 }
178 if channel.dissolved && event.kind.as_u16() != event_kind::COMMUNITY_DELETE {
186 return None;
187 }
188 let opened = match open_message_multi(event, &channel.id, &channel.read_epoch_keys()) {
192 Ok(o) => o,
193 Err(e) => {
194 crate::log_debug!("[community] inbound drop {}: {}", event.id.to_hex(), e);
195 return None;
196 }
197 };
198 if channel.banned.contains(&opened.author) {
201 crate::log_debug!("[community] dropped event from banned author {}", opened.author.to_hex());
202 return None;
203 }
204 let outcome = match opened.kind {
205 k if k == event_kind::COMMUNITY_MESSAGE => {
206 ingest_message(state, &opened, my_pubkey).map(IncomingEvent::NewMessage)
207 }
208 k if k == event_kind::COMMUNITY_REACTION => apply_reaction(state, &opened, my_pubkey),
209 k if k == event_kind::COMMUNITY_EDIT => apply_edit(state, &opened, my_pubkey),
210 k if k == event_kind::COMMUNITY_DELETE => apply_delete(state, &opened, channel, my_pubkey),
211 k if k == event_kind::COMMUNITY_PRESENCE => apply_presence(&opened, channel, my_pubkey),
212 k if k == event_kind::COMMUNITY_KICK => apply_kick(&opened, channel, my_pubkey),
213 k if k == event_kind::COMMUNITY_WEBXDC => apply_webxdc(&opened, my_pubkey),
214 k if k == event_kind::COMMUNITY_TYPING => apply_typing(&opened, my_pubkey),
215 _ => None,
216 };
217 if let Some(ref evt) = outcome {
223 if !matches!(evt, IncomingEvent::NewMessage(_) | IncomingEvent::Typing { .. }) {
224 let _ = crate::db::wrappers::save_processed_wrapper(
225 &outer_bytes, event.created_at.as_secs(), crate::db::wrappers::TRANSPORT_CONCORD,
226 );
227 }
228 }
229 outcome
230}
231
232fn apply_presence(opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
236 let joined = opened.content != "leave";
238 if !joined && opened.author == *my_pubkey {
242 if let Ok(Some(cid)) = crate::db::community::community_id_for_channel(&channel.id.to_hex()) {
243 let cid_bytes = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid));
251 let join_ms = crate::db::community::community_created_at_ms(&cid_bytes).unwrap_or(0);
252 if opened.created_at.as_secs().saturating_mul(1000) > join_ms {
253 return Some(IncomingEvent::SelfLeft { community_id: cid });
254 }
255 crate::log_debug!("[community] self-leave predates this join — rendering as history, not teardown");
256 }
257 }
258 let (invited_by, invited_label) = if joined {
259 serde_json::from_str::<serde_json::Value>(&opened.content)
260 .ok()
261 .map(|v| {
262 let by = v.get("by").and_then(|b| b.as_str())
266 .filter(|s| PublicKey::parse(s).is_ok())
267 .map(str::to_string);
268 let label = v.get("l").and_then(|l| l.as_str())
269 .map(|s| s.chars().take(48).collect::<String>())
270 .filter(|s| !s.is_empty());
271 (by, label)
272 })
273 .unwrap_or((None, None))
274 } else {
275 (None, None)
276 };
277 Some(IncomingEvent::Presence {
278 npub: opened.author.to_bech32().ok()?,
279 joined,
280 event_id: opened.message_id.to_hex(),
281 created_at: clamp_inner_secs(opened.created_at.as_secs()),
282 invited_by,
283 invited_label,
284 })
285}
286
287fn clamp_inner_secs(secs: u64) -> u64 {
291 let now = std::time::SystemTime::now()
292 .duration_since(std::time::UNIX_EPOCH)
293 .map(|d| d.as_secs())
294 .unwrap_or(0);
295 secs.min(now + 300)
296}
297
298fn apply_webxdc(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
304 if opened.author == *my_pubkey {
305 return None;
306 }
307 let (topic_id, node_addr) = crate::webxdc::parse_peer_signal(&opened.content)?;
308 Some(IncomingEvent::WebxdcPeer {
309 npub: opened.author.to_bech32().ok()?,
310 topic_id,
311 node_addr,
312 event_id: opened.message_id.to_hex(),
313 created_at: opened.created_at.as_secs(),
314 })
315}
316
317fn apply_typing(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
322 if opened.author == *my_pubkey {
323 return None;
324 }
325 if opened.content != "typing" {
326 return None;
327 }
328 let now = std::time::SystemTime::now()
329 .duration_since(std::time::UNIX_EPOCH)
330 .map(|d| d.as_secs())
331 .unwrap_or(0);
332 let until = opened.created_at.as_secs() + 30;
337 if until <= now || opened.created_at.as_secs() > now + 30 {
338 return None;
339 }
340 Some(IncomingEvent::Typing {
341 npub: opened.author.to_bech32().ok()?,
342 until,
343 })
344}
345
346fn apply_kick(opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
357 use crate::community::roles::Permissions;
358 let target = PublicKey::parse(opened.content.trim()).ok()?;
359 let target_hex = target.to_hex();
360 let kicker_hex = opened.author.to_hex();
361 let owner_hex = channel.protected.first().map(|pk| pk.to_hex());
362 let pinned = actor_authority_pinned(channel, owner_hex.as_deref(), &kicker_hex, opened.citation.as_ref());
363 if !(pinned && channel.roster.can_act_on_member(&kicker_hex, owner_hex.as_deref(), &target_hex, Permissions::KICK)) {
364 crate::log_debug!("[community] dropped kick: {kicker_hex} not authorized to kick {target_hex}");
365 return None;
366 }
367 let cid_hex = crate::db::community::community_id_for_channel(&channel.id.to_hex()).ok().flatten()?;
368 let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
373 let join_ms = crate::db::community::community_created_at_ms(&cid).unwrap_or(0);
374 if opened.created_at.as_secs().saturating_mul(1000) <= join_ms {
377 crate::log_debug!("[community] dropped stale kick of {target_hex} (predates this join)");
378 return None;
379 }
380 if target == *my_pubkey {
381 return Some(IncomingEvent::Kicked { community_id: cid_hex });
382 }
383 Some(IncomingEvent::Presence {
385 npub: target.to_bech32().ok()?,
386 joined: false,
387 event_id: opened.message_id.to_hex(),
388 created_at: clamp_inner_secs(opened.created_at.as_secs()),
389 invited_by: None,
390 invited_label: None,
391 })
392}
393
394pub fn event_authenticates(event: &Event, channel: &Channel) -> bool {
407 if crate::db::events::wrapper_event_exists(&event.id.to_hex()).unwrap_or(false)
408 || crate::db::wrappers::processed_wrapper_exists(&event.id.to_bytes())
409 {
410 return true;
411 }
412 open_message_multi(event, &channel.id, &channel.read_epoch_keys()).is_ok()
413}
414
415pub fn process_channel_batch(
429 state: &mut ChatState,
430 events: &[Event],
431 channel: &Channel,
432 my_pubkey: &PublicKey,
433) -> Vec<IncomingEvent> {
434 let mut out = Vec::new();
435 for want_message in [true, false] {
438 for ev in events {
439 let is_message = ev.kind.as_u16() == event_kind::COMMUNITY_MESSAGE;
440 if is_message != want_message {
441 continue;
442 }
443 if let Some(evt) = process_incoming(state, ev, channel, my_pubkey) {
444 out.push(evt);
445 }
446 }
447 }
448 out
449}
450
451fn apply_reaction(state: &mut ChatState, opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
455 use crate::rumor::{process_rumor, RumorProcessingResult};
456 let (rumor, ctx) = concord_rumor(opened, nostr_sdk::prelude::Kind::Reaction, my_pubkey);
457 let reaction = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
458 Ok(RumorProcessingResult::Reaction(r)) => r,
459 _ => return None,
460 };
461 let target_id = reaction.reference_id.clone();
462 let expected_chat = opened.channel_id.to_hex();
467 if !matches!(state.find_message(&target_id), Some((chat, _)) if chat.id == expected_chat) {
468 return None;
469 }
470 let (_chat_id, was_added) = state.add_reaction_to_message(&target_id, reaction)?;
471 if !was_added {
472 return None;
473 }
474 let (_chat, message) = state.find_message(&target_id)?;
475 Some(IncomingEvent::Updated { target_id, message, edit_event: None })
476}
477
478fn apply_edit(state: &mut ChatState, opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
482 use crate::rumor::{process_rumor, RumorProcessingResult};
483 let (rumor, ctx) = concord_rumor(opened, nostr_sdk::prelude::Kind::from(event_kind::MESSAGE_EDIT), my_pubkey);
484 let (target_id, new_content, edited_at, emoji_tags, edit_event) = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
485 Ok(RumorProcessingResult::Edit { message_id, new_content, edited_at, emoji_tags, event }) => (message_id, new_content, edited_at, emoji_tags, event),
486 _ => return None,
487 };
488 let editor_npub = opened.author.to_bech32().ok()?;
491 let target_author = state.find_message(&target_id).and_then(|(_, m)| m.npub)?;
492 if target_author != editor_npub {
493 crate::log_debug!("[community] dropped edit from non-author of {}", target_id);
494 return None;
495 }
496 let (_chat_id, message) = state.update_message(&target_id, |m| {
499 m.apply_edit(new_content.clone(), edited_at, emoji_tags.clone());
500 })?;
501 Some(IncomingEvent::Updated { target_id, message, edit_event: Some(Box::new(edit_event)) })
504}
505
506fn actor_authority_pinned(
516 channel: &Channel,
517 owner_hex: Option<&str>,
518 actor_hex: &str,
519 citation: Option<&super::edition::AuthorityCitation>,
520) -> bool {
521 if owner_hex == Some(actor_hex) {
522 return true; }
524 if citation.is_none() {
525 return false; }
527 let Ok(Some(cid)) = crate::db::community::community_id_for_channel(&channel.id.to_hex()) else {
528 return false; };
530 let cid_bytes = crate::simd::hex::hex_to_bytes_32(&cid);
531 let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
532 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
533 &crate::community::CommunityId(cid_bytes),
534 &actor_bytes,
535 ));
536 let head: Vec<super::roster::EntityHead> = crate::db::community::get_edition_head(&cid, &grant_hex)
537 .ok()
538 .flatten()
539 .map(|(version, self_hash)| super::roster::EntityHead { entity_hex: grant_hex.clone(), version, self_hash, inner_id: [0u8; 32], citation: None })
540 .into_iter()
541 .collect();
542 super::roster::authority_citation_satisfied(&head, owner_hex, actor_hex, &grant_hex, citation)
543}
544
545fn apply_delete(state: &mut ChatState, opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
546 use crate::community::roles::Permissions;
547 use crate::rumor::{process_rumor, RumorProcessingResult};
548 let (rumor, ctx) = concord_rumor(opened, nostr_sdk::prelude::Kind::EventDeletion, my_pubkey);
552 let target_id = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
553 Ok(RumorProcessingResult::DeletionRequest { target_event_id }) => target_event_id,
554 _ => return None,
555 };
556 let deleter = opened.author;
557 let deleter_hex = deleter.to_hex();
558
559 if let Some((_chat_id, message_id, author_npub, _is_comm)) = state.find_reaction(&target_id) {
563 let reactor_ok = PublicKey::parse(&author_npub).map(|pk| pk == deleter).unwrap_or(false);
564 if !reactor_ok {
565 crate::log_debug!("[community] dropped reaction-revoke: {deleter_hex} is not the reactor of {target_id}");
566 return None;
567 }
568 return state
569 .remove_reaction_from_message(&message_id, &target_id)
570 .map(|(_cid, message)| IncomingEvent::ReactionRemoved {
571 message_id,
572 reaction_id: target_id,
573 message,
574 });
575 }
576
577 let owner_hex = channel.protected.first().map(|pk| pk.to_hex());
578 let pinned = actor_authority_pinned(channel, owner_hex.as_deref(), &deleter_hex, opened.citation.as_ref());
581
582 let target_author = state
585 .find_message(&target_id)
586 .and_then(|(_, m)| m.npub.clone())
587 .and_then(|n| PublicKey::parse(&n).ok());
588
589 if let Some(author) = target_author {
590 if author == deleter {
592 return state.remove_message(&target_id).map(|_| IncomingEvent::Removed { target_id });
593 }
594 if pinned && !channel.dissolved && channel.roster.can_act_on_member(&deleter_hex, owner_hex.as_deref(), &author.to_hex(), Permissions::MANAGE_MESSAGES) {
600 return state.remove_message(&target_id).map(|_| IncomingEvent::Removed { target_id });
601 }
602 crate::log_debug!("[community] dropped delete: {deleter_hex} not authorized to remove {target_id}");
603 return None;
604 }
605
606 if let Ok(Some(author_npub)) = crate::db::events::event_author(&target_id) {
610 if let Ok(author) = PublicKey::parse(&author_npub) {
611 let ok = author == deleter
615 || (pinned
616 && !channel.dissolved
617 && channel.roster.can_act_on_member(&deleter_hex, owner_hex.as_deref(), &author.to_hex(), Permissions::MANAGE_MESSAGES));
618 if ok {
619 return Some(IncomingEvent::Removed { target_id });
620 }
621 crate::log_debug!("[community] dropped out-of-window delete: {deleter_hex} not authorized over {target_id}");
622 return None;
623 }
624 }
625
626 None
634}
635
636pub fn route_incoming(
642 state: &mut ChatState,
643 event: &Event,
644 routes: &std::collections::HashMap<String, Channel>,
645 my_pubkey: &PublicKey,
646) -> Option<IncomingEvent> {
647 let pseudonym = event.tags.iter().find_map(|t| {
648 let s = t.as_slice();
649 (s.len() >= 2 && s[0] == "z").then(|| s[1].clone())
650 })?;
651 let channel = routes.get(&pseudonym)?;
652 process_incoming(state, event, channel, my_pubkey)
653}
654
655#[cfg(test)]
656mod tests {
657 use nostr_sdk::prelude::FinalizeEvent;
658 use super::*;
659 use crate::community::derive::channel_pseudonym;
660 use std::collections::HashMap;
661 use crate::community::envelope::{build_inner_full, build_inner_typed, open_message, seal_message, seal_with_signed_inner};
662 use crate::community::edition::AuthorityCitation;
663 use crate::community::{Channel, ChannelId, ChannelKey, Epoch};
664 use crate::state::ChatState;
665 use nostr_sdk::prelude::{Keys, Tag};
666
667 fn db_roster_channel(
673 owner: &Keys,
674 admin: &PublicKey,
675 ) -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Channel, AuthorityCitation) {
676 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
677 use nostr_sdk::prelude::ToBech32;
678 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
679 crate::db::close_database();
680 let tmp = tempfile::tempdir().unwrap();
681 let account = owner.public_key().to_bech32().unwrap();
682 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
683 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
684 crate::db::set_current_account(account.clone()).unwrap();
685 crate::db::init_database(&account).unwrap();
686 crate::state::MY_SECRET_KEY.store_from_keys(owner, &[]);
687 crate::state::set_my_public_key(owner.public_key());
688
689 let mut community = crate::community::Community::create("HQ", "general", vec!["r".into()]);
690 let cid = community.id.to_hex();
691 community.owner_attestation = Some(
692 crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &cid)
693 .finalize(owner)
694 .unwrap()
695 .as_json(),
696 );
697 crate::db::community::save_community(&community).unwrap();
698
699 let role = Role::admin("a".repeat(64));
702 let roster = CommunityRoles {
703 grants: vec![MemberGrant { member: admin.to_hex(), role_ids: vec![role.role_id.clone()] }],
704 roles: vec![role],
705 };
706 crate::db::community::set_community_roles(&cid, &roster, 0).unwrap();
707 let entity_id = crate::community::derive::grant_locator(&community.id, &admin.to_bytes());
708 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
709 let hash = [0x5Au8; 32];
710 crate::db::community::set_edition_head(&cid, &entity_hex, 1, &hash).unwrap();
711
712 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
713 let channel = reloaded.channels[0].clone();
714 (tmp, guard, channel, AuthorityCitation { entity_id, version: 1, edition_hash: hash })
715 }
716
717 fn seal_hide(channel: &Channel, author: &Keys, target: &str, ms: u64, citation: Option<&AuthorityCitation>) -> Event {
719 let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
720 let inner = build_inner_full(
721 author.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_DELETE, "", ms, Some(target), &[], &extra,
722 )
723 .finalize(author)
724 .unwrap();
725 seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
726 }
727
728 fn ingest_msg_in(state: &mut ChatState, channel: &Channel, author: &Keys, content: &str, ms: u64, viewer: &Keys) -> String {
730 let outer = seal_message(author, &channel.key, &channel.id, channel.epoch, content, ms).unwrap();
731 match process_incoming(state, &outer, channel, &viewer.public_key()) {
732 Some(IncomingEvent::NewMessage(m)) => m.id,
733 _ => panic!("expected a new message"),
734 }
735 }
736
737 fn opened_from(author: &Keys, content: &str, ms: u64) -> OpenedMessage {
738 let key = ChannelKey([0x33u8; 32]);
739 let chan = ChannelId([0x44u8; 32]);
740 let outer = seal_message(author, &key, &chan, Epoch(0), content, ms).unwrap();
741 open_message(&outer, &key, &chan, Epoch(0)).unwrap()
742 }
743
744 fn test_channel() -> Channel {
745 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 }
746 }
747
748 fn seal_typed(author: &Keys, kind: u16, content: &str, ms: u64, target: &str) -> Event {
750 let c = test_channel();
751 let inner = build_inner_typed(author.public_key(), &c.id, c.epoch, kind, content, ms, Some(target), &[])
752 .finalize(author)
753 .unwrap();
754 seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
755 }
756
757 fn ingest_msg(state: &mut ChatState, author: &Keys, content: &str, ms: u64, viewer: &Keys) -> String {
758 let c = test_channel();
759 let outer = seal_message(author, &c.key, &c.id, c.epoch, content, ms).unwrap();
760 match process_incoming(state, &outer, &c, &viewer.public_key()) {
761 Some(IncomingEvent::NewMessage(m)) => m.id,
762 _ => panic!("expected a new message"),
763 }
764 }
765
766 #[test]
767 fn inbound_reaction_applies_to_target_and_dedups() {
768 use crate::stored_event::event_kind;
769 let mut state = ChatState::new();
770 let alice = Keys::generate();
771 let bob = Keys::generate();
772 let target = ingest_msg(&mut state, &alice, "hi", 1, &bob);
773
774 let react = seal_typed(&bob, event_kind::COMMUNITY_REACTION, "🔥", 2, &target);
775 match process_incoming(&mut state, &react, &test_channel(), &bob.public_key()) {
776 Some(IncomingEvent::Updated { target_id, message, edit_event: None }) => {
777 assert_eq!(target_id, target);
778 assert!(message.reactions.iter().any(|r| r.emoji == "🔥"), "reaction applied to target");
779 }
780 _ => panic!("expected a reaction update"),
781 }
782 assert!(process_incoming(&mut state, &react, &test_channel(), &bob.public_key()).is_none());
784 }
785
786 #[test]
787 fn bot_routing_tag_rides_the_v1_inner_into_addressed_bots() {
788 use nostr_sdk::prelude::ToBech32;
789 use crate::community::envelope::{build_inner_full, seal_with_signed_inner};
790 let mut state = ChatState::new();
791 let alice = Keys::generate();
792 let bot = Keys::generate();
793 let c = test_channel();
794
795 let inner = build_inner_full(
797 alice.public_key(),
798 &c.id,
799 c.epoch,
800 crate::stored_event::event_kind::COMMUNITY_MESSAGE,
801 "/roll 20",
802 5,
803 None,
804 &[],
805 &[crate::bot_interface::bot_tag(&bot.public_key())],
806 )
807 .finalize(&alice)
808 .unwrap();
809 let outer = seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap();
810
811 let opened = open_message(&outer, &c.key, &c.id, c.epoch).unwrap();
814 let msg = build_message(&opened, &alice.public_key());
815 assert_eq!(msg.addressed_bots, vec![bot.public_key().to_bech32().unwrap()]);
816
817 match process_incoming(&mut state, &outer, &c, &alice.public_key()) {
818 Some(IncomingEvent::NewMessage(m)) => {
819 assert_eq!(m.addressed_bots.len(), 1, "ingest keeps the routing tag");
820 }
821 _ => panic!("expected a new message"),
822 }
823 }
824
825 #[test]
826 fn reaction_cross_channel_is_rejected() {
827 use crate::stored_event::event_kind;
830 use crate::community::envelope::{build_inner_typed, seal_with_signed_inner};
831 let mut state = ChatState::new();
832 let alice = Keys::generate();
833 let bob = Keys::generate();
834 let chan_a = test_channel();
835 let chan_b = Channel {
836 id: ChannelId([0x55u8; 32]), key: ChannelKey([0x66u8; 32]), epoch: Epoch(0),
837 name: "b".into(), banned: Vec::new(), protected: Vec::new(),
838 roster: Default::default(), epoch_keys: Vec::new(), dissolved: false,
839 };
840 let target = ingest_msg_in(&mut state, &chan_a, &alice, "hi", 1, &bob);
842 let inner = build_inner_typed(
844 bob.public_key(), &chan_b.id, chan_b.epoch, event_kind::COMMUNITY_REACTION, "🔥", 2, Some(&target), &[],
845 ).finalize(&bob).unwrap();
846 let outer = seal_with_signed_inner(&Keys::generate(), &inner, &chan_b.key, &chan_b.id, chan_b.epoch).unwrap();
847 assert!(
849 process_incoming(&mut state, &outer, &chan_b, &bob.public_key()).is_none(),
850 "a reaction sealed under another channel must not apply to this channel's message"
851 );
852 let (_c, msg) = state.find_message(&target).unwrap();
853 assert!(msg.reactions.is_empty(), "cross-channel reaction must not be applied");
854 }
855
856 #[test]
857 fn inbound_message_carries_multi_attachments() {
858 use crate::stored_event::event_kind;
859 use crate::community::attachments::attachment_to_imeta;
860 use crate::community::envelope::build_inner_full;
861 use crate::types::Attachment;
862 let mut state = ChatState::new();
863 let alice = Keys::generate();
864 let bob = Keys::generate();
865 let c = test_channel();
866
867 let mk = |n: &str, ext: &str| Attachment {
868 id: "x".into(), key: "0".repeat(64), nonce: format!("{:0<24}", crate::simd::hex::bytes_to_hex_string(n.as_bytes())),
869 extension: ext.into(), name: n.into(), url: format!("https://b/{n}"),
870 path: String::new(), size: 9, img_meta: None, downloading: false, downloaded: false,
871 webxdc_topic: None, group_id: None, original_hash: Some("a".repeat(64)),
872 fallback_urls: Vec::new(),
873 };
874 let imetas = vec![attachment_to_imeta(&mk("a.png", "png")), attachment_to_imeta(&mk("b.txt", "txt"))];
875 let inner = build_inner_full(
876 alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_MESSAGE,
877 "caption", 5, None, &[], &imetas,
878 ).finalize(&alice).unwrap();
879 let outer = seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap();
880
881 match process_incoming(&mut state, &outer, &c, &bob.public_key()) {
882 Some(IncomingEvent::NewMessage(m)) => {
883 assert_eq!(m.content, "caption", "caption + attachments coexist in one event");
884 assert_eq!(m.attachments.len(), 2);
885 assert_eq!(m.attachments[0].name, "a.png");
886 assert_eq!(m.attachments[1].name, "b.txt");
887 assert!(m.attachments.iter().all(|a| a.group_id.is_none()));
888 }
889 _ => panic!("expected new message with attachments"),
890 }
891 }
892
893 #[test]
894 fn inbound_edit_only_honored_from_original_author() {
895 use crate::stored_event::event_kind;
896 let mut state = ChatState::new();
897 let alice = Keys::generate();
898 let target = ingest_msg(&mut state, &alice, "original", 1, &alice);
899
900 let edit = seal_typed(&alice, event_kind::COMMUNITY_EDIT, "edited!", 2, &target);
902 match process_incoming(&mut state, &edit, &test_channel(), &alice.public_key()) {
903 Some(IncomingEvent::Updated { message, edit_event, .. }) => {
904 assert_eq!(message.content, "edited!");
905 assert!(message.edited);
906 let ev = edit_event.expect("edit surfaces a MESSAGE_EDIT event to persist");
908 assert_eq!(ev.kind, event_kind::MESSAGE_EDIT);
909 assert_eq!(ev.reference_id.as_deref(), Some(target.as_str()));
910 assert_eq!(ev.content, "edited!");
911 }
912 _ => panic!("expected an edit update"),
913 }
914
915 let mallory = Keys::generate();
917 let hijack = seal_typed(&mallory, event_kind::COMMUNITY_EDIT, "hijacked", 3, &target);
918 assert!(process_incoming(&mut state, &hijack, &test_channel(), &alice.public_key()).is_none());
919 assert_eq!(state.find_message(&target).unwrap().1.content, "edited!");
920 }
921
922 #[test]
923 fn cooperative_delete_only_honored_from_original_author() {
924 use crate::stored_event::event_kind;
925 let mut state = ChatState::new();
926 let alice = Keys::generate();
927 let mallory = Keys::generate();
928 let target = ingest_msg(&mut state, &alice, "secret", 1, &alice);
929
930 let hijack = seal_typed(&mallory, event_kind::COMMUNITY_DELETE, "", 2, &target);
932 assert!(process_incoming(&mut state, &hijack, &test_channel(), &alice.public_key()).is_none());
933 assert!(state.find_message(&target).is_some(), "non-author delete must not remove");
934
935 let del = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 3, &target);
937 match process_incoming(&mut state, &del, &test_channel(), &alice.public_key()) {
938 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
939 _ => panic!("expected a removal"),
940 }
941 assert!(state.find_message(&target).is_none(), "message gone after author delete");
942
943 let replay = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 4, &target);
945 assert!(process_incoming(&mut state, &replay, &test_channel(), &alice.public_key()).is_none());
946 }
947
948 #[test]
949 fn dissolved_community_still_honors_an_own_message_delete() {
950 use crate::stored_event::event_kind;
951 let mut state = ChatState::new();
952 let alice = Keys::generate();
953 let target = ingest_msg(&mut state, &alice, "alice's own message", 1, &alice);
954 let mut ch = test_channel();
955 ch.dissolved = true;
956 let del = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 2, &target);
959 match process_incoming(&mut state, &del, &ch, &alice.public_key()) {
960 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
961 _ => panic!("a self-delete must be honored in a dissolved community"),
962 }
963 assert!(state.find_message(&target).is_none(), "own message scrubbed from the dead community");
964 }
965
966 #[test]
967 fn admin_moderation_hide_removes_any_message() {
968 let owner = Keys::generate();
969 let admin = Keys::generate(); let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
971 let alice = Keys::generate(); let mallory = Keys::generate(); let mut state = ChatState::new();
974 let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
975
976 let hijack = seal_hide(&c, &mallory, &target, 2, None);
978 assert!(process_incoming(&mut state, &hijack, &c, &alice.public_key()).is_none());
979 assert!(state.find_message(&target).is_some(), "unprivileged hide rejected");
980
981 let hide = seal_hide(&c, &admin, &target, 3, Some(&cite));
983 match process_incoming(&mut state, &hide, &c, &alice.public_key()) {
984 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
985 _ => panic!("expected admin moderation-hide to remove the message"),
986 }
987 assert!(state.find_message(&target).is_none(), "admin hide removed the message");
988 }
989
990 #[test]
991 fn admin_hide_without_a_citation_is_dropped() {
992 let owner = Keys::generate();
996 let admin = Keys::generate();
997 let (_tmp, _guard, c, _cite) = db_roster_channel(&owner, &admin.public_key());
998 let alice = Keys::generate();
999 let mut state = ChatState::new();
1000 let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
1001
1002 let hide = seal_hide(&c, &admin, &target, 2, None); assert!(process_incoming(&mut state, &hide, &c, &alice.public_key()).is_none());
1004 assert!(state.find_message(&target).is_some(), "an uncited admin hide is dropped");
1005 }
1006
1007 #[test]
1008 fn hide_citing_an_unsynced_grant_version_is_dropped() {
1009 let owner = Keys::generate();
1013 let admin = Keys::generate();
1014 let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1015 let alice = Keys::generate();
1016 let mut state = ChatState::new();
1017 let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
1018
1019 let ahead = AuthorityCitation { version: 2, ..cite };
1021 let hide = seal_hide(&c, &admin, &target, 2, Some(&ahead));
1022 assert!(process_incoming(&mut state, &hide, &c, &alice.public_key()).is_none());
1023 assert!(state.find_message(&target).is_some(), "a hide citing an unsynced version is dropped");
1024 }
1025
1026 #[test]
1027 fn hide_with_a_forged_citation_hash_is_dropped() {
1028 let owner = Keys::generate();
1030 let admin = Keys::generate();
1031 let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1032 let alice = Keys::generate();
1033 let mut state = ChatState::new();
1034 let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
1035
1036 let forged = AuthorityCitation { edition_hash: [0xEE; 32], ..cite };
1037 let hide = seal_hide(&c, &admin, &target, 2, Some(&forged));
1038 assert!(process_incoming(&mut state, &hide, &c, &alice.public_key()).is_none());
1039 assert!(state.find_message(&target).is_some(), "a forged-hash citation is dropped");
1040 }
1041
1042 #[test]
1043 fn protected_owner_cannot_be_moderation_hidden_but_others_can() {
1044 let owner = Keys::generate(); let admin = Keys::generate(); let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1047 let mut state = ChatState::new();
1048
1049 let owners_msg = ingest_msg_in(&mut state, &c, &owner, "owner speaks", 1, &owner);
1051 let hide_owner = seal_hide(&c, &admin, &owners_msg, 2, Some(&cite));
1052 assert!(process_incoming(&mut state, &hide_owner, &c, &owner.public_key()).is_none());
1053 assert!(state.find_message(&owners_msg).is_some(), "owner's message is protected");
1054
1055 let member = Keys::generate();
1057 let members_msg = ingest_msg_in(&mut state, &c, &member, "member speaks", 3, &owner);
1058 let hide_member = seal_hide(&c, &admin, &members_msg, 4, Some(&cite));
1059 match process_incoming(&mut state, &hide_member, &c, &owner.public_key()) {
1060 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, members_msg),
1061 _ => panic!("a non-protected member's message should be hideable"),
1062 }
1063 }
1064
1065 #[test]
1066 fn admin_hide_of_absent_target_defers_until_resident() {
1067 let owner = Keys::generate();
1075 let admin = Keys::generate();
1076 let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1077 let mut state = ChatState::new();
1078 let absent_target = "f".repeat(64); let hide = seal_hide(&c, &admin, &absent_target, 1, Some(&cite));
1081 assert!(
1082 process_incoming(&mut state, &hide, &c, &Keys::generate().public_key()).is_none(),
1083 "a hide of an absent target defers (None) rather than falsely tombstoning + self-deduping",
1084 );
1085
1086 let mallory = Keys::generate();
1088 let hijack = seal_hide(&c, &mallory, &absent_target, 2, None);
1089 assert!(process_incoming(&mut state, &hijack, &c, &mallory.public_key()).is_none());
1090
1091 let uncited = seal_hide(&c, &admin, &absent_target, 3, None);
1094 assert!(
1095 process_incoming(&mut state, &uncited, &c, &Keys::generate().public_key()).is_none(),
1096 "an admin's uncited hide of an unknown target is dropped (pinned gates the author-unknown path)"
1097 );
1098 }
1099
1100 #[tokio::test]
1101 async fn out_of_window_hide_authorizes_against_db_author() {
1102 use crate::types::Message;
1103 use nostr_sdk::prelude::ToBech32;
1107 let owner = Keys::generate();
1108 let admin = Keys::generate(); let member = Keys::generate();
1110 let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1111
1112 let owner_msg = "a".repeat(64);
1114 let member_msg = "b".repeat(64);
1115 let mk = |id: &str, author: &Keys, at: u64| {
1116 let mut m = Message::default();
1117 m.id = id.to_string();
1118 m.npub = Some(author.public_key().to_bech32().unwrap());
1119 m.at = at;
1120 m
1121 };
1122 crate::db::events::save_message("chatoow", &mk(&owner_msg, &owner, 1)).await.unwrap();
1123 crate::db::events::save_message("chatoow", &mk(&member_msg, &member, 2)).await.unwrap();
1124
1125 let mut state = ChatState::new();
1126 let hide_owner = seal_hide(&c, &admin, &owner_msg, 3, Some(&cite));
1128 assert!(
1129 process_incoming(&mut state, &hide_owner, &c, &member.public_key()).is_none(),
1130 "owner's paged-out message must not be hideable by an admin"
1131 );
1132 let hide_member = seal_hide(&c, &admin, &member_msg, 4, Some(&cite));
1134 match process_incoming(&mut state, &hide_member, &c, &owner.public_key()) {
1135 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, member_msg),
1136 _ => panic!("admin should hide a member's paged-out message"),
1137 }
1138
1139 let member_msg2 = "c".repeat(64);
1143 crate::db::events::save_message("chatoow", &mk(&member_msg2, &member, 5)).await.unwrap();
1144 let mut sealed = c.clone();
1145 sealed.dissolved = true;
1146 let hide_sealed = seal_hide(&sealed, &admin, &member_msg2, 6, Some(&cite));
1147 assert!(
1148 process_incoming(&mut state, &hide_sealed, &sealed, &owner.public_key()).is_none(),
1149 "a dissolved community accepts no moderation-hide, resident or paged-out"
1150 );
1151 let self_del = seal_hide(&sealed, &member, &member_msg2, 7, None);
1152 match process_incoming(&mut state, &self_del, &sealed, &owner.public_key()) {
1153 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, member_msg2),
1154 _ => panic!("a self-delete of a paged-out message must survive the dissolved seal"),
1155 }
1156 crate::db::close_database();
1157 }
1158
1159 fn seal_kick(channel: &Channel, author: &Keys, target_hex: &str, ms: u64, citation: Option<&AuthorityCitation>) -> Event {
1161 let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
1162 let inner = build_inner_full(
1163 author.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_KICK, target_hex, ms, None, &[], &extra,
1164 )
1165 .finalize(author)
1166 .unwrap();
1167 seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
1168 }
1169
1170 fn post_join_ms() -> u64 {
1173 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
1174 (now + 5) * 1000
1175 }
1176
1177 #[test]
1178 fn cited_admin_kick_of_local_user_yields_self_removal() {
1179 let owner = Keys::generate();
1180 let admin = Keys::generate();
1181 let member = Keys::generate();
1182 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1183 let mut state = ChatState::new();
1184 let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), post_join_ms(), Some(&cite));
1186 match process_incoming(&mut state, &kick, &channel, &member.public_key()) {
1187 Some(IncomingEvent::Kicked { community_id }) => assert!(!community_id.is_empty()),
1188 _ => panic!("expected Kicked"),
1189 }
1190 crate::db::close_database();
1191 }
1192
1193 #[test]
1194 fn cited_admin_kick_of_other_member_is_a_leave() {
1195 use nostr_sdk::prelude::ToBech32;
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(), post_join_ms(), Some(&cite));
1204 match process_incoming(&mut state, &kick, &channel, &owner.public_key()) {
1205 Some(IncomingEvent::Presence { npub, joined, .. }) => {
1206 assert!(!joined);
1207 assert_eq!(npub, member.public_key().to_bech32().unwrap());
1208 }
1209 _ => panic!("expected leave Presence"),
1210 }
1211 crate::db::close_database();
1212 }
1213
1214 #[test]
1215 fn uncited_kick_is_dropped() {
1216 let owner = Keys::generate();
1217 let admin = Keys::generate();
1218 let member = Keys::generate();
1219 let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1220 let mut state = ChatState::new();
1221 let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), 1, None);
1222 assert!(process_incoming(&mut state, &kick, &channel, &member.public_key()).is_none(),
1223 "a non-owner kick without a citation is dropped");
1224 crate::db::close_database();
1225 }
1226
1227 #[test]
1228 fn unprivileged_kick_is_dropped() {
1229 let owner = Keys::generate();
1230 let admin = Keys::generate();
1231 let mallory = Keys::generate();
1232 let member = Keys::generate();
1233 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1234 let mut state = ChatState::new();
1235 let kick = seal_kick(&channel, &mallory, &member.public_key().to_hex(), 1, Some(&cite));
1238 assert!(process_incoming(&mut state, &kick, &channel, &member.public_key()).is_none(),
1239 "a kick from an unranked actor is dropped");
1240 crate::db::close_database();
1241 }
1242
1243 #[test]
1244 fn kick_of_owner_is_dropped() {
1245 let owner = Keys::generate();
1246 let admin = Keys::generate();
1247 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1248 let mut state = ChatState::new();
1249 let kick = seal_kick(&channel, &admin, &owner.public_key().to_hex(), post_join_ms(), Some(&cite));
1251 assert!(process_incoming(&mut state, &kick, &channel, &owner.public_key()).is_none(),
1252 "an admin cannot kick the owner");
1253 crate::db::close_database();
1254 }
1255
1256 #[test]
1257 fn stale_kick_predating_join_is_dropped() {
1258 let owner = Keys::generate();
1259 let admin = Keys::generate();
1260 let member = Keys::generate();
1261 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1262 let mut state = ChatState::new();
1263 let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), 1, Some(&cite));
1266 assert!(process_incoming(&mut state, &kick, &channel, &member.public_key()).is_none(),
1267 "a kick older than the current join is dropped");
1268 crate::db::close_database();
1269 }
1270
1271 #[test]
1272 fn webxdc_signals_parse_ad_and_left_and_reject_garbage() {
1273 use crate::stored_event::event_kind;
1274 use nostr_sdk::prelude::ToBech32;
1275 let mut state = ChatState::new();
1276 let alice = Keys::generate();
1277 let c = test_channel();
1278 let viewer = Keys::generate();
1279 let mk = |content: &str, ms: u64| {
1280 let inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_WEBXDC, content, ms, None, &[])
1281 .finalize(&alice).unwrap();
1282 seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
1283 };
1284 let topic = crate::webxdc::mint_topic_id("game-hash", "sender");
1285
1286 let ad = serde_json::json!({ "op": "ad", "topic": topic, "addr": "BASE32NODEADDR" }).to_string();
1288 match process_incoming(&mut state, &mk(&ad, 1), &c, &viewer.public_key()) {
1289 Some(IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, .. }) => {
1290 assert_eq!(npub, alice.public_key().to_bech32().unwrap(), "player is the inner author");
1291 assert_eq!(topic_id, topic);
1292 assert_eq!(node_addr.as_deref(), Some("BASE32NODEADDR"));
1293 }
1294 _ => panic!("expected a webxdc advertisement"),
1295 }
1296
1297 let left = serde_json::json!({ "op": "left", "topic": topic }).to_string();
1299 match process_incoming(&mut state, &mk(&left, 2), &c, &viewer.public_key()) {
1300 Some(IncomingEvent::WebxdcPeer { node_addr, .. }) => {
1301 assert!(node_addr.is_none(), "peer-left carries no addr");
1302 }
1303 _ => panic!("expected a webxdc peer-left"),
1304 }
1305
1306 assert!(
1308 process_incoming(&mut state, &mk(&ad, 3), &c, &alice.public_key()).is_none(),
1309 "own webxdc signal must be ignored"
1310 );
1311
1312 let bad_topic = serde_json::json!({ "op": "ad", "topic": "../../etc", "addr": "X" }).to_string();
1314 assert!(process_incoming(&mut state, &mk(&bad_topic, 4), &c, &viewer.public_key()).is_none());
1315 let bad_op = serde_json::json!({ "op": "explode", "topic": topic }).to_string();
1316 assert!(process_incoming(&mut state, &mk(&bad_op, 5), &c, &viewer.public_key()).is_none());
1317 let no_addr = serde_json::json!({ "op": "ad", "topic": topic }).to_string();
1318 assert!(process_incoming(&mut state, &mk(&no_addr, 6), &c, &viewer.public_key()).is_none());
1319 assert!(process_incoming(&mut state, &mk("not json", 7), &c, &viewer.public_key()).is_none());
1320 }
1321
1322 #[test]
1323 fn typing_indicator_parses_drops_own_echo_and_rejects_garbage() {
1324 use crate::stored_event::event_kind;
1325 use nostr_sdk::prelude::ToBech32;
1326 let mut state = ChatState::new();
1327 let alice = Keys::generate();
1328 let c = test_channel();
1329 let viewer = Keys::generate();
1330 let mk = |content: &str, ms: u64| {
1331 let inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_TYPING, content, ms, None, &[])
1332 .finalize(&alice).unwrap();
1333 seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
1334 };
1335 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
1336
1337 match process_incoming(&mut state, &mk("typing", now * 1000), &c, &viewer.public_key()) {
1341 Some(IncomingEvent::Typing { npub, until }) => {
1342 assert_eq!(npub, alice.public_key().to_bech32().unwrap(), "typer is the inner author");
1343 assert!(until >= now && until <= now + 31, "until = signal send time + 30s");
1344 }
1345 _ => panic!("expected a typing indicator"),
1346 }
1347
1348 assert!(
1350 process_incoming(&mut state, &mk("typing", now * 1000), &c, &alice.public_key()).is_none(),
1351 "own typing signal must be ignored"
1352 );
1353
1354 assert!(process_incoming(&mut state, &mk("nope", now * 1000), &c, &viewer.public_key()).is_none());
1356 }
1357
1358 #[test]
1359 fn presence_announcements_parse_join_and_leave() {
1360 use crate::stored_event::event_kind;
1361 let mut state = ChatState::new();
1362 let alice = Keys::generate();
1363 let c = test_channel();
1364 let viewer = Keys::generate();
1365 let mk = |content: &str, ms: u64| {
1366 let inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_PRESENCE, content, ms, None, &[])
1367 .finalize(&alice).unwrap();
1368 seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
1369 };
1370 match process_incoming(&mut state, &mk("join", 1), &c, &viewer.public_key()) {
1371 Some(IncomingEvent::Presence { npub, joined, .. }) => {
1372 assert!(joined, "content 'join' → joined");
1373 assert_eq!(npub, alice.public_key().to_bech32().unwrap(), "announcer is the inner author");
1374 }
1375 _ => panic!("expected a join presence"),
1376 }
1377 match process_incoming(&mut state, &mk("leave", 2), &c, &viewer.public_key()) {
1378 Some(IncomingEvent::Presence { joined, invited_by, .. }) => {
1379 assert!(!joined, "content 'leave' → not joined");
1380 assert!(invited_by.is_none(), "a plain leave carries no attribution");
1381 }
1382 _ => panic!("expected a leave presence"),
1383 }
1384 let jean = Keys::generate().public_key().to_bech32().unwrap();
1387 let attributed = serde_json::json!({ "by": jean, "l": "Reddit" }).to_string();
1388 match process_incoming(&mut state, &mk(&attributed, 3), &c, &viewer.public_key()) {
1389 Some(IncomingEvent::Presence { joined, invited_by, invited_label, .. }) => {
1390 assert!(joined, "an attributed-join JSON is still a join");
1391 assert_eq!(invited_by.as_deref(), Some(jean.as_str()), "valid inviter npub surfaced");
1392 assert_eq!(invited_label.as_deref(), Some("Reddit"), "link label surfaced");
1393 }
1394 _ => panic!("expected an attributed join presence"),
1395 }
1396 let forged = serde_json::json!({ "by": "haha not an npub", "l": "x" }).to_string();
1398 match process_incoming(&mut state, &mk(&forged, 4), &c, &viewer.public_key()) {
1399 Some(IncomingEvent::Presence { invited_by, .. }) => assert!(invited_by.is_none(), "forged inviter dropped"),
1400 _ => panic!("expected a join presence"),
1401 }
1402 }
1403
1404 #[test]
1405 fn leave_presence_authored_by_local_npub_yields_self_left() {
1406 use crate::stored_event::event_kind;
1407 let owner = Keys::generate();
1410 let admin = Keys::generate();
1411 let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1412 let mut state = ChatState::new();
1413 let cid = crate::db::community::community_id_for_channel(&channel.id.to_hex()).unwrap().unwrap();
1416 let cid_bytes = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid));
1417 let leave_ms = crate::db::community::community_created_at_ms(&cid_bytes).unwrap_or(0) + 10_000;
1418 let leave = {
1419 let inner = build_inner_typed(owner.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_PRESENCE, "leave", leave_ms, None, &[])
1420 .finalize(&owner).unwrap();
1421 seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
1422 };
1423 match process_incoming(&mut state, &leave, &channel, &owner.public_key()) {
1424 Some(IncomingEvent::SelfLeft { community_id }) => assert!(!community_id.is_empty()),
1425 _ => panic!("expected SelfLeft"),
1426 }
1427 crate::db::close_database();
1428 }
1429
1430 #[test]
1431 fn leave_presence_authored_by_another_npub_stays_a_plain_leave() {
1432 use crate::stored_event::event_kind;
1433 use nostr_sdk::prelude::ToBech32;
1434 let owner = Keys::generate();
1436 let admin = Keys::generate();
1437 let other = Keys::generate();
1438 let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1439 let mut state = ChatState::new();
1440 let leave = {
1441 let inner = build_inner_typed(other.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_PRESENCE, "leave", 2, None, &[])
1442 .finalize(&other).unwrap();
1443 seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
1444 };
1445 match process_incoming(&mut state, &leave, &channel, &owner.public_key()) {
1447 Some(IncomingEvent::Presence { npub, joined, .. }) => {
1448 assert!(!joined);
1449 assert_eq!(npub, other.public_key().to_bech32().unwrap());
1450 }
1451 _ => panic!("expected plain leave Presence"),
1452 }
1453 crate::db::close_database();
1454 }
1455
1456 #[test]
1457 fn self_delete_still_applies_after_keep_keys_teardown() {
1458 let owner = Keys::generate();
1461 let admin = Keys::generate();
1462 let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1463 let cid = crate::db::community::community_id_for_channel(&channel.id.to_hex()).unwrap().unwrap();
1464 let chan_hex = channel.id.to_hex();
1465 let epoch = channel.epoch.0;
1466
1467 let mut state = ChatState::new();
1469 let target = ingest_msg_in(&mut state, &channel, &owner, "mine", 1, &owner);
1470
1471 crate::db::community::delete_community_retain_keys(&cid).unwrap();
1473 let retained = crate::db::community::held_epoch_key(&cid, &chan_hex, epoch).unwrap()
1474 .expect("epoch key retained after keep-keys teardown");
1475 let mut rebuilt = channel.clone();
1476 rebuilt.key = ChannelKey(retained);
1477 rebuilt.epoch = Epoch(epoch);
1478
1479 let del = seal_hide(&rebuilt, &owner, &target, 2, None);
1481 match process_incoming(&mut state, &del, &rebuilt, &owner.public_key()) {
1482 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
1483 _ => panic!("expected the self-delete to apply under the retained key"),
1484 }
1485 crate::db::close_database();
1486 }
1487
1488 #[test]
1489 fn banned_author_events_are_dropped_including_presence() {
1490 use crate::stored_event::event_kind;
1491 let mut state = ChatState::new();
1492 let alice = Keys::generate(); let bob = Keys::generate();
1494 let mut c = test_channel();
1495 c.banned = vec![alice.public_key()];
1496
1497 let spam = seal_message(&alice, &c.key, &c.id, c.epoch, "spam", 1).unwrap();
1499 assert!(process_incoming(&mut state, &spam, &c, &bob.public_key()).is_none(), "banned message dropped");
1500
1501 let pres_inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_PRESENCE, "join", 2, None, &[])
1503 .finalize(&alice).unwrap();
1504 let pres = seal_with_signed_inner(&Keys::generate(), &pres_inner, &c.key, &c.id, c.epoch).unwrap();
1505 assert!(process_incoming(&mut state, &pres, &c, &bob.public_key()).is_none(), "banned presence dropped");
1506
1507 let ok = seal_message(&bob, &c.key, &c.id, c.epoch, "hi", 3).unwrap();
1509 assert!(matches!(process_incoming(&mut state, &ok, &c, &bob.public_key()), Some(IncomingEvent::NewMessage(_))), "non-banned applied");
1510 }
1511
1512 #[test]
1513 fn cooperative_delete_applies_after_message_in_batch_order() {
1514 use crate::stored_event::event_kind;
1515 let mut state = ChatState::new();
1516 let alice = Keys::generate();
1517 let c = test_channel();
1518
1519 let msg_outer = seal_message(&alice, &c.key, &c.id, c.epoch, "bye", 1).unwrap();
1523 let opened = open_message(&msg_outer, &c.key, &c.id, c.epoch).unwrap();
1524 let inner_id = opened.message_id.to_hex();
1525 let del_outer = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 2, &inner_id);
1526
1527 let applied = process_channel_batch(&mut state, &[del_outer, msg_outer], &c, &alice.public_key());
1528 assert!(applied.iter().any(|e| matches!(e, IncomingEvent::NewMessage(_))));
1529 assert!(applied.iter().any(|e| matches!(e, IncomingEvent::Removed { .. })));
1530 assert!(state.find_message(&inner_id).is_none(), "delete applied despite arriving first");
1531 }
1532
1533 #[test]
1534 fn build_message_sets_mine_and_author() {
1535 let me = Keys::generate();
1536 let opened = opened_from(&me, "hello", 4242);
1537 let msg = build_message(&opened, &me.public_key());
1538 assert_eq!(msg.content, "hello");
1539 assert_eq!(msg.at, 4242);
1540 assert!(msg.mine, "author == me → mine");
1541 assert_eq!(msg.npub, me.public_key().to_bech32().ok());
1542 assert_eq!(msg.id, opened.message_id.to_hex());
1543
1544 let other_view = build_message(&opened, &Keys::generate().public_key());
1546 assert!(!other_view.mine);
1547 }
1548
1549 #[test]
1550 fn ingest_creates_community_chat_and_adds_message() {
1551 let mut state = ChatState::new();
1552 let alice = Keys::generate();
1553 let opened = opened_from(&alice, "gm", 1);
1554
1555 assert!(ingest_message(&mut state, &opened, &alice.public_key()).is_some());
1556 let chat = state.chats.iter().find(|c| c.id == opened.channel_id.to_hex()).expect("chat");
1558 assert!(chat.is_community(), "channel chat must be ChatType::Community");
1559 }
1560
1561 #[test]
1562 fn process_incoming_ingests_valid_drops_foreign() {
1563 let mut state = ChatState::new();
1564 let alice = Keys::generate();
1565 let key = ChannelKey([0x33u8; 32]);
1566 let chan = ChannelId([0x44u8; 32]);
1567 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 };
1568
1569 let outer = seal_message(&alice, &key, &chan, Epoch(0), "real", 1).unwrap();
1571 assert!(process_incoming(&mut state, &outer, &channel, &alice.public_key()).is_some());
1572 assert!(state.chats.iter().any(|c| c.is_community()));
1573
1574 let other_key = ChannelKey([0x99u8; 32]);
1576 let other_chan = ChannelId([0xaau8; 32]);
1577 let foreign = seal_message(&alice, &other_key, &other_chan, Epoch(0), "nope", 1).unwrap();
1578 assert!(process_incoming(&mut state, &foreign, &channel, &alice.public_key()).is_none());
1579 assert_eq!(state.chats.iter().filter(|c| c.is_community()).count(), 1);
1580 }
1581
1582 #[test]
1583 fn ingest_dedups_on_message_id() {
1584 let mut state = ChatState::new();
1585 let alice = Keys::generate();
1586 let opened = opened_from(&alice, "once", 1);
1587
1588 assert!(ingest_message(&mut state, &opened, &alice.public_key()).is_some(), "first add");
1589 assert!(ingest_message(&mut state, &opened, &alice.public_key()).is_none(), "duplicate not re-added");
1590 assert_eq!(state.chats.iter().filter(|c| c.is_community()).count(), 1);
1592 }
1593
1594 #[test]
1595 fn dedup_keys_on_inner_id_across_distinct_outer_events() {
1596 let mut state = ChatState::new();
1602 let alice = Keys::generate();
1603 let key = ChannelKey([0x33u8; 32]);
1604 let chan = ChannelId([0x44u8; 32]);
1605 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 };
1606
1607 let outer_a = seal_message(&alice, &key, &chan, Epoch(0), "dup", 7).unwrap();
1608 let outer_b = seal_message(&alice, &key, &chan, Epoch(0), "dup", 7).unwrap();
1609 assert_ne!(outer_a.id, outer_b.id, "distinct outer events (fresh ephemeral + nonce)");
1610
1611 assert!(process_incoming(&mut state, &outer_a, &channel, &alice.public_key()).is_some());
1612 assert!(
1613 process_incoming(&mut state, &outer_b, &channel, &alice.public_key()).is_none(),
1614 "same inner message id must dedup despite a different outer event"
1615 );
1616 }
1617
1618 #[test]
1619 fn route_incoming_routes_by_pseudonym() {
1620 let mut state = ChatState::new();
1621 let alice = Keys::generate();
1622 let key = ChannelKey([0x33u8; 32]);
1623 let chan = ChannelId([0x44u8; 32]);
1624 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 };
1625
1626 let mut routes = HashMap::new();
1628 routes.insert(channel_pseudonym(&key, &chan, Epoch(0)).to_hex(), channel.clone());
1629
1630 let outer = seal_message(&alice, &key, &chan, Epoch(0), "routed", 1).unwrap();
1632 assert!(route_incoming(&mut state, &outer, &routes, &alice.public_key()).is_some());
1633
1634 let other_key = ChannelKey([0x55u8; 32]);
1636 let other_chan = ChannelId([0x66u8; 32]);
1637 let unrouted = seal_message(&alice, &other_key, &other_chan, Epoch(0), "x", 1).unwrap();
1638 assert!(route_incoming(&mut state, &unrouted, &routes, &alice.public_key()).is_none());
1639 }
1640
1641 #[test]
1642 fn ms_none_falls_back_to_created_at() {
1643 use nostr_sdk::prelude::{EventId, Timestamp, Tags};
1645 let author = Keys::generate();
1646 let opened = OpenedMessage {
1647 message_id: EventId::from_byte_array([0u8; 32]),
1648 author: author.public_key(),
1649 content: "no ms".into(),
1650 channel_id: ChannelId([1u8; 32]),
1651 epoch: Epoch(0),
1652 ms: None,
1653 created_at: Timestamp::from_secs(1500),
1654 kind: 3300,
1655 attachments: vec![],
1656 citation: None,
1657 wrapper_id: EventId::from_byte_array([0u8; 32]),
1658 tags: Tags::new(),
1659 };
1660 assert_eq!(build_message(&opened, &author.public_key()).at, 1_500_000);
1661 }
1662
1663 #[test]
1667 fn replayed_old_typing_signals_are_dropped() {
1668 let author = Keys::generate();
1669 let me = Keys::generate();
1670 let now_ms = std::time::SystemTime::now()
1671 .duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
1672
1673 let fresh = opened_from(&author, "typing", now_ms);
1674 match apply_typing(&fresh, &me.public_key()) {
1675 Some(IncomingEvent::Typing { until, .. }) => {
1676 assert!(until > now_ms / 1000, "a live signal stays visible");
1677 assert!(until <= now_ms / 1000 + 31, "expiry derives from the signal, not the clock");
1678 }
1679 _ => panic!("fresh signal must show typing"),
1680 }
1681
1682 let replayed = opened_from(&author, "typing", now_ms - 3_600_000);
1683 assert!(apply_typing(&replayed, &me.public_key()).is_none(), "an hour-old replay never paints");
1684
1685 let future = opened_from(&author, "typing", now_ms + 600_000);
1686 assert!(apply_typing(&future, &me.public_key()).is_none(), "future-dated junk never pins a bubble");
1687 }
1688}