1use nostr_sdk::prelude::{FinalizeEvent, FinalizeUnsignedEvent};
7use nostr_sdk::prelude::{Event, PublicKey};
8use nostr_sdk::prelude::ToBech32;
9
10use super::envelope::{open_message_multi, OpenedMessage};
11use super::Channel;
12use crate::state::ChatState;
13use crate::stored_event::event_kind;
14use crate::types::Message;
15
16fn concord_rumor(
28 opened: &OpenedMessage,
29 kind: nostr_sdk::prelude::Kind,
30 my_pubkey: &PublicKey,
31) -> (crate::rumor::RumorEvent, crate::rumor::RumorContext) {
32 use crate::rumor::{ConversationType, RumorContext, RumorEvent};
33 (
34 RumorEvent {
35 id: opened.message_id,
36 kind,
37 content: opened.content.clone(),
38 tags: opened.tags.clone(),
39 created_at: opened.created_at,
40 pubkey: opened.author,
41 },
42 RumorContext {
43 sender: opened.author,
44 is_mine: opened.author == *my_pubkey,
45 conversation_id: opened.channel_id.to_hex(),
46 conversation_type: ConversationType::Community,
47 },
48 )
49}
50
51pub fn build_message(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Message {
52 use crate::rumor::{process_rumor, RumorProcessingResult};
53 let (rumor, ctx) = concord_rumor(opened, nostr_sdk::prelude::Kind::PrivateDirectMessage, my_pubkey);
54 let mut msg = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
55 Ok(RumorProcessingResult::TextMessage(m)) => m,
56 _ => Message {
59 id: opened.message_id.to_hex(),
60 content: opened.content.clone(),
61 at: opened.ms.unwrap_or_else(|| opened.created_at.as_secs().saturating_mul(1000)),
62 mine: opened.author == *my_pubkey,
63 npub: opened.author.to_bech32().ok(),
64 ..Default::default()
65 },
66 };
67 msg.attachments = opened.attachments.clone();
70 msg.content = super::attachments::strip_attachment_urls(&msg.content, &msg.attachments);
72 msg.wrapper_event_id = Some(opened.wrapper_id.to_hex());
73 msg
74}
75
76pub fn ingest_message(
81 state: &mut ChatState,
82 opened: &OpenedMessage,
83 my_pubkey: &PublicKey,
84) -> Option<Message> {
85 let chat_id = opened.channel_id.to_hex();
86 let msg = build_message(opened, my_pubkey);
87 if crate::db::events::event_exists(&msg.id).unwrap_or(false) {
94 return None;
95 }
96 state.ensure_community_chat(&chat_id);
97 if state.add_message_to_chat(&chat_id, &msg) {
98 Some(msg)
99 } else {
100 None
101 }
102}
103
104pub enum IncomingEvent {
109 NewMessage(Message),
110 Updated { target_id: String, message: Message, edit_event: Option<Box<crate::stored_event::StoredEvent>> },
115 Removed { target_id: String },
116 ReactionRemoved { message_id: String, reaction_id: String, message: Message },
121 Presence { npub: String, joined: bool, event_id: String, created_at: u64, invited_by: Option<String>, invited_label: Option<String> },
128 Kicked { community_id: String },
133 SelfLeft { community_id: String },
139 WebxdcPeer {
144 npub: String,
145 topic_id: String,
146 node_addr: Option<String>,
148 event_id: String,
149 created_at: u64,
150 },
151 Typing { npub: String, until: u64 },
155}
156
157pub fn process_incoming(
163 state: &mut ChatState,
164 event: &Event,
165 channel: &Channel,
166 my_pubkey: &PublicKey,
167) -> Option<IncomingEvent> {
168 let outer_bytes = event.id.to_bytes();
174 if crate::db::events::wrapper_event_exists(&event.id.to_hex()).unwrap_or(false)
175 || crate::db::wrappers::processed_wrapper_exists(&outer_bytes)
176 {
177 return None;
178 }
179 if channel.dissolved && event.kind.as_u16() != event_kind::COMMUNITY_DELETE {
187 return None;
188 }
189 let opened = match open_message_multi(event, &channel.id, &channel.read_epoch_keys()) {
193 Ok(o) => o,
194 Err(e) => {
195 crate::log_debug!("[community] inbound drop {}: {}", event.id.to_hex(), e);
196 return None;
197 }
198 };
199 if channel.banned.contains(&opened.author) {
202 crate::log_debug!("[community] dropped event from banned author {}", opened.author.to_hex());
203 return None;
204 }
205 let outcome = match opened.kind {
206 k if k == event_kind::COMMUNITY_MESSAGE => {
207 ingest_message(state, &opened, my_pubkey).map(IncomingEvent::NewMessage)
208 }
209 k if k == event_kind::COMMUNITY_REACTION => apply_reaction(state, &opened, my_pubkey),
210 k if k == event_kind::COMMUNITY_EDIT => apply_edit(state, &opened, my_pubkey),
211 k if k == event_kind::COMMUNITY_DELETE => apply_delete(state, &opened, channel, my_pubkey),
212 k if k == event_kind::COMMUNITY_PRESENCE => apply_presence(&opened, channel, my_pubkey),
213 k if k == event_kind::COMMUNITY_KICK => apply_kick(&opened, channel, my_pubkey),
214 k if k == event_kind::COMMUNITY_WEBXDC => apply_webxdc(&opened, my_pubkey),
215 k if k == event_kind::COMMUNITY_TYPING => apply_typing(&opened, my_pubkey),
216 _ => None,
217 };
218 if let Some(ref evt) = outcome {
224 if !matches!(evt, IncomingEvent::NewMessage(_) | IncomingEvent::Typing { .. }) {
225 let _ = crate::db::wrappers::save_processed_wrapper(
226 &outer_bytes, event.created_at.as_secs(), crate::db::wrappers::TRANSPORT_CONCORD,
227 );
228 }
229 }
230 outcome
231}
232
233fn apply_presence(opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
237 let joined = opened.content != "leave";
239 if !joined && opened.author == *my_pubkey {
243 if let Ok(Some(cid)) = crate::db::community::community_id_for_channel(&channel.id.to_hex()) {
244 let cid_bytes = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid));
252 let join_ms = crate::db::community::community_created_at_ms(&cid_bytes).unwrap_or(0);
253 if opened.created_at.as_secs().saturating_mul(1000) > join_ms {
254 return Some(IncomingEvent::SelfLeft { community_id: cid });
255 }
256 crate::log_debug!("[community] self-leave predates this join — rendering as history, not teardown");
257 }
258 }
259 let (invited_by, invited_label) = if joined {
260 serde_json::from_str::<serde_json::Value>(&opened.content)
261 .ok()
262 .map(|v| {
263 let by = v.get("by").and_then(|b| b.as_str())
267 .filter(|s| PublicKey::parse(s).is_ok())
268 .map(str::to_string);
269 let label = v.get("l").and_then(|l| l.as_str())
270 .map(|s| s.chars().take(48).collect::<String>())
271 .filter(|s| !s.is_empty());
272 (by, label)
273 })
274 .unwrap_or((None, None))
275 } else {
276 (None, None)
277 };
278 Some(IncomingEvent::Presence {
279 npub: opened.author.to_bech32().ok()?,
280 joined,
281 event_id: opened.message_id.to_hex(),
282 created_at: clamp_inner_secs(opened.created_at.as_secs()),
283 invited_by,
284 invited_label,
285 })
286}
287
288fn clamp_inner_secs(secs: u64) -> u64 {
292 let now = std::time::SystemTime::now()
293 .duration_since(std::time::UNIX_EPOCH)
294 .map(|d| d.as_secs())
295 .unwrap_or(0);
296 secs.min(now + 300)
297}
298
299fn apply_webxdc(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
305 if opened.author == *my_pubkey {
306 return None;
307 }
308 let (topic_id, node_addr) = crate::webxdc::parse_peer_signal(&opened.content)?;
309 Some(IncomingEvent::WebxdcPeer {
310 npub: opened.author.to_bech32().ok()?,
311 topic_id,
312 node_addr,
313 event_id: opened.message_id.to_hex(),
314 created_at: opened.created_at.as_secs(),
315 })
316}
317
318fn apply_typing(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
323 if opened.author == *my_pubkey {
324 return None;
325 }
326 if opened.content != "typing" {
327 return None;
328 }
329 let now = std::time::SystemTime::now()
330 .duration_since(std::time::UNIX_EPOCH)
331 .map(|d| d.as_secs())
332 .unwrap_or(0);
333 Some(IncomingEvent::Typing {
334 npub: opened.author.to_bech32().ok()?,
335 until: now + 30,
336 })
337}
338
339fn apply_kick(opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
350 use crate::community::roles::Permissions;
351 let target = PublicKey::parse(opened.content.trim()).ok()?;
352 let target_hex = target.to_hex();
353 let kicker_hex = opened.author.to_hex();
354 let owner_hex = channel.protected.first().map(|pk| pk.to_hex());
355 let pinned = actor_authority_pinned(channel, owner_hex.as_deref(), &kicker_hex, opened.citation.as_ref());
356 if !(pinned && channel.roster.can_act_on_member(&kicker_hex, owner_hex.as_deref(), &target_hex, Permissions::KICK)) {
357 crate::log_debug!("[community] dropped kick: {kicker_hex} not authorized to kick {target_hex}");
358 return None;
359 }
360 let cid_hex = crate::db::community::community_id_for_channel(&channel.id.to_hex()).ok().flatten()?;
361 let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
366 let join_ms = crate::db::community::community_created_at_ms(&cid).unwrap_or(0);
367 if opened.created_at.as_secs().saturating_mul(1000) <= join_ms {
370 crate::log_debug!("[community] dropped stale kick of {target_hex} (predates this join)");
371 return None;
372 }
373 if target == *my_pubkey {
374 return Some(IncomingEvent::Kicked { community_id: cid_hex });
375 }
376 Some(IncomingEvent::Presence {
378 npub: target.to_bech32().ok()?,
379 joined: false,
380 event_id: opened.message_id.to_hex(),
381 created_at: clamp_inner_secs(opened.created_at.as_secs()),
382 invited_by: None,
383 invited_label: None,
384 })
385}
386
387pub fn event_authenticates(event: &Event, channel: &Channel) -> bool {
400 if crate::db::events::wrapper_event_exists(&event.id.to_hex()).unwrap_or(false)
401 || crate::db::wrappers::processed_wrapper_exists(&event.id.to_bytes())
402 {
403 return true;
404 }
405 open_message_multi(event, &channel.id, &channel.read_epoch_keys()).is_ok()
406}
407
408pub fn process_channel_batch(
422 state: &mut ChatState,
423 events: &[Event],
424 channel: &Channel,
425 my_pubkey: &PublicKey,
426) -> Vec<IncomingEvent> {
427 let mut out = Vec::new();
428 for want_message in [true, false] {
431 for ev in events {
432 let is_message = ev.kind.as_u16() == event_kind::COMMUNITY_MESSAGE;
433 if is_message != want_message {
434 continue;
435 }
436 if let Some(evt) = process_incoming(state, ev, channel, my_pubkey) {
437 out.push(evt);
438 }
439 }
440 }
441 out
442}
443
444fn apply_reaction(state: &mut ChatState, opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
448 use crate::rumor::{process_rumor, RumorProcessingResult};
449 let (rumor, ctx) = concord_rumor(opened, nostr_sdk::prelude::Kind::Reaction, my_pubkey);
450 let reaction = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
451 Ok(RumorProcessingResult::Reaction(r)) => r,
452 _ => return None,
453 };
454 let target_id = reaction.reference_id.clone();
455 let expected_chat = opened.channel_id.to_hex();
460 if !matches!(state.find_message(&target_id), Some((chat, _)) if chat.id == expected_chat) {
461 return None;
462 }
463 let (_chat_id, was_added) = state.add_reaction_to_message(&target_id, reaction)?;
464 if !was_added {
465 return None;
466 }
467 let (_chat, message) = state.find_message(&target_id)?;
468 Some(IncomingEvent::Updated { target_id, message, edit_event: None })
469}
470
471fn apply_edit(state: &mut ChatState, opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
475 use crate::rumor::{process_rumor, RumorProcessingResult};
476 let (rumor, ctx) = concord_rumor(opened, nostr_sdk::prelude::Kind::from(event_kind::MESSAGE_EDIT), my_pubkey);
477 let (target_id, new_content, edited_at, emoji_tags, edit_event) = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
478 Ok(RumorProcessingResult::Edit { message_id, new_content, edited_at, emoji_tags, event }) => (message_id, new_content, edited_at, emoji_tags, event),
479 _ => return None,
480 };
481 let editor_npub = opened.author.to_bech32().ok()?;
484 let target_author = state.find_message(&target_id).and_then(|(_, m)| m.npub)?;
485 if target_author != editor_npub {
486 crate::log_debug!("[community] dropped edit from non-author of {}", target_id);
487 return None;
488 }
489 let (_chat_id, message) = state.update_message(&target_id, |m| {
492 m.apply_edit(new_content.clone(), edited_at, emoji_tags.clone());
493 })?;
494 Some(IncomingEvent::Updated { target_id, message, edit_event: Some(Box::new(edit_event)) })
497}
498
499fn actor_authority_pinned(
509 channel: &Channel,
510 owner_hex: Option<&str>,
511 actor_hex: &str,
512 citation: Option<&super::edition::AuthorityCitation>,
513) -> bool {
514 if owner_hex == Some(actor_hex) {
515 return true; }
517 if citation.is_none() {
518 return false; }
520 let Ok(Some(cid)) = crate::db::community::community_id_for_channel(&channel.id.to_hex()) else {
521 return false; };
523 let cid_bytes = crate::simd::hex::hex_to_bytes_32(&cid);
524 let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
525 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
526 &crate::community::CommunityId(cid_bytes),
527 &actor_bytes,
528 ));
529 let head: Vec<super::roster::EntityHead> = crate::db::community::get_edition_head(&cid, &grant_hex)
530 .ok()
531 .flatten()
532 .map(|(version, self_hash)| super::roster::EntityHead { entity_hex: grant_hex.clone(), version, self_hash, inner_id: [0u8; 32], citation: None })
533 .into_iter()
534 .collect();
535 super::roster::authority_citation_satisfied(&head, owner_hex, actor_hex, &grant_hex, citation)
536}
537
538fn apply_delete(state: &mut ChatState, opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
539 use crate::community::roles::Permissions;
540 use crate::rumor::{process_rumor, RumorProcessingResult};
541 let (rumor, ctx) = concord_rumor(opened, nostr_sdk::prelude::Kind::EventDeletion, my_pubkey);
545 let target_id = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
546 Ok(RumorProcessingResult::DeletionRequest { target_event_id }) => target_event_id,
547 _ => return None,
548 };
549 let deleter = opened.author;
550 let deleter_hex = deleter.to_hex();
551
552 if let Some((_chat_id, message_id, author_npub, _is_comm)) = state.find_reaction(&target_id) {
556 let reactor_ok = PublicKey::parse(&author_npub).map(|pk| pk == deleter).unwrap_or(false);
557 if !reactor_ok {
558 crate::log_debug!("[community] dropped reaction-revoke: {deleter_hex} is not the reactor of {target_id}");
559 return None;
560 }
561 return state
562 .remove_reaction_from_message(&message_id, &target_id)
563 .map(|(_cid, message)| IncomingEvent::ReactionRemoved {
564 message_id,
565 reaction_id: target_id,
566 message,
567 });
568 }
569
570 let owner_hex = channel.protected.first().map(|pk| pk.to_hex());
571 let pinned = actor_authority_pinned(channel, owner_hex.as_deref(), &deleter_hex, opened.citation.as_ref());
574
575 let target_author = state
578 .find_message(&target_id)
579 .and_then(|(_, m)| m.npub.clone())
580 .and_then(|n| PublicKey::parse(&n).ok());
581
582 if let Some(author) = target_author {
583 if author == deleter {
585 return state.remove_message(&target_id).map(|_| IncomingEvent::Removed { target_id });
586 }
587 if pinned && !channel.dissolved && channel.roster.can_act_on_member(&deleter_hex, owner_hex.as_deref(), &author.to_hex(), Permissions::MANAGE_MESSAGES) {
593 return state.remove_message(&target_id).map(|_| IncomingEvent::Removed { target_id });
594 }
595 crate::log_debug!("[community] dropped delete: {deleter_hex} not authorized to remove {target_id}");
596 return None;
597 }
598
599 if let Ok(Some(author_npub)) = crate::db::events::event_author(&target_id) {
603 if let Ok(author) = PublicKey::parse(&author_npub) {
604 let ok = author == deleter
608 || (pinned
609 && !channel.dissolved
610 && channel.roster.can_act_on_member(&deleter_hex, owner_hex.as_deref(), &author.to_hex(), Permissions::MANAGE_MESSAGES));
611 if ok {
612 return Some(IncomingEvent::Removed { target_id });
613 }
614 crate::log_debug!("[community] dropped out-of-window delete: {deleter_hex} not authorized over {target_id}");
615 return None;
616 }
617 }
618
619 None
627}
628
629pub fn route_incoming(
635 state: &mut ChatState,
636 event: &Event,
637 routes: &std::collections::HashMap<String, Channel>,
638 my_pubkey: &PublicKey,
639) -> Option<IncomingEvent> {
640 let pseudonym = event.tags.iter().find_map(|t| {
641 let s = t.as_slice();
642 (s.len() >= 2 && s[0] == "z").then(|| s[1].clone())
643 })?;
644 let channel = routes.get(&pseudonym)?;
645 process_incoming(state, event, channel, my_pubkey)
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use crate::community::derive::channel_pseudonym;
652 use std::collections::HashMap;
653 use crate::community::envelope::{build_inner_full, build_inner_typed, open_message, seal_message, seal_with_signed_inner};
654 use crate::community::edition::AuthorityCitation;
655 use crate::community::{Channel, ChannelId, ChannelKey, Epoch};
656 use crate::state::ChatState;
657 use nostr_sdk::prelude::{Keys, Tag};
658
659 fn db_roster_channel(
665 owner: &Keys,
666 admin: &PublicKey,
667 ) -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Channel, AuthorityCitation) {
668 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
669 use nostr_sdk::prelude::ToBech32;
670 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
671 crate::db::close_database();
672 let tmp = tempfile::tempdir().unwrap();
673 let account = owner.public_key().to_bech32().unwrap();
674 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
675 crate::db::set_app_data_dir(tmp.path().to_path_buf());
676 crate::db::set_current_account(account.clone()).unwrap();
677 crate::db::init_database(&account).unwrap();
678 crate::state::MY_SECRET_KEY.store_from_keys(owner, &[]);
679 crate::state::set_my_public_key(owner.public_key());
680
681 let mut community = crate::community::Community::create("HQ", "general", vec!["r".into()]);
682 let cid = community.id.to_hex();
683 community.owner_attestation = Some(
684 crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &cid)
685 .finalize(owner)
686 .unwrap()
687 .as_json(),
688 );
689 crate::db::community::save_community(&community).unwrap();
690
691 let role = Role::admin("a".repeat(64));
694 let roster = CommunityRoles {
695 grants: vec![MemberGrant { member: admin.to_hex(), role_ids: vec![role.role_id.clone()] }],
696 roles: vec![role],
697 };
698 crate::db::community::set_community_roles(&cid, &roster, 0).unwrap();
699 let entity_id = crate::community::derive::grant_locator(&community.id, &admin.to_bytes());
700 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
701 let hash = [0x5Au8; 32];
702 crate::db::community::set_edition_head(&cid, &entity_hex, 1, &hash).unwrap();
703
704 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
705 let channel = reloaded.channels[0].clone();
706 (tmp, guard, channel, AuthorityCitation { entity_id, version: 1, edition_hash: hash })
707 }
708
709 fn seal_hide(channel: &Channel, author: &Keys, target: &str, ms: u64, citation: Option<&AuthorityCitation>) -> Event {
711 let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
712 let inner = build_inner_full(
713 author.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_DELETE, "", ms, Some(target), &[], &extra,
714 )
715 .finalize(author)
716 .unwrap();
717 seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
718 }
719
720 fn ingest_msg_in(state: &mut ChatState, channel: &Channel, author: &Keys, content: &str, ms: u64, viewer: &Keys) -> String {
722 let outer = seal_message(author, &channel.key, &channel.id, channel.epoch, content, ms).unwrap();
723 match process_incoming(state, &outer, channel, &viewer.public_key()) {
724 Some(IncomingEvent::NewMessage(m)) => m.id,
725 _ => panic!("expected a new message"),
726 }
727 }
728
729 fn opened_from(author: &Keys, content: &str, ms: u64) -> OpenedMessage {
730 let key = ChannelKey([0x33u8; 32]);
731 let chan = ChannelId([0x44u8; 32]);
732 let outer = seal_message(author, &key, &chan, Epoch(0), content, ms).unwrap();
733 open_message(&outer, &key, &chan, Epoch(0)).unwrap()
734 }
735
736 fn test_channel() -> Channel {
737 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 }
738 }
739
740 fn seal_typed(author: &Keys, kind: u16, content: &str, ms: u64, target: &str) -> Event {
742 let c = test_channel();
743 let inner = build_inner_typed(author.public_key(), &c.id, c.epoch, kind, content, ms, Some(target), &[])
744 .finalize(author)
745 .unwrap();
746 seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
747 }
748
749 fn ingest_msg(state: &mut ChatState, author: &Keys, content: &str, ms: u64, viewer: &Keys) -> String {
750 let c = test_channel();
751 let outer = seal_message(author, &c.key, &c.id, c.epoch, content, ms).unwrap();
752 match process_incoming(state, &outer, &c, &viewer.public_key()) {
753 Some(IncomingEvent::NewMessage(m)) => m.id,
754 _ => panic!("expected a new message"),
755 }
756 }
757
758 #[test]
759 fn inbound_reaction_applies_to_target_and_dedups() {
760 use crate::stored_event::event_kind;
761 let mut state = ChatState::new();
762 let alice = Keys::generate();
763 let bob = Keys::generate();
764 let target = ingest_msg(&mut state, &alice, "hi", 1, &bob);
765
766 let react = seal_typed(&bob, event_kind::COMMUNITY_REACTION, "🔥", 2, &target);
767 match process_incoming(&mut state, &react, &test_channel(), &bob.public_key()) {
768 Some(IncomingEvent::Updated { target_id, message, edit_event: None }) => {
769 assert_eq!(target_id, target);
770 assert!(message.reactions.iter().any(|r| r.emoji == "🔥"), "reaction applied to target");
771 }
772 _ => panic!("expected a reaction update"),
773 }
774 assert!(process_incoming(&mut state, &react, &test_channel(), &bob.public_key()).is_none());
776 }
777
778 #[test]
779 fn bot_routing_tag_rides_the_v1_inner_into_addressed_bots() {
780 use nostr_sdk::prelude::ToBech32;
781 use crate::community::envelope::{build_inner_full, seal_with_signed_inner};
782 let mut state = ChatState::new();
783 let alice = Keys::generate();
784 let bot = Keys::generate();
785 let c = test_channel();
786
787 let inner = build_inner_full(
789 alice.public_key(),
790 &c.id,
791 c.epoch,
792 crate::stored_event::event_kind::COMMUNITY_MESSAGE,
793 "/roll 20",
794 5,
795 None,
796 &[],
797 &[crate::bot_interface::bot_tag(&bot.public_key())],
798 )
799 .finalize(&alice)
800 .unwrap();
801 let outer = seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap();
802
803 let opened = open_message(&outer, &c.key, &c.id, c.epoch).unwrap();
806 let msg = build_message(&opened, &alice.public_key());
807 assert_eq!(msg.addressed_bots, vec![bot.public_key().to_bech32().unwrap()]);
808
809 match process_incoming(&mut state, &outer, &c, &alice.public_key()) {
810 Some(IncomingEvent::NewMessage(m)) => {
811 assert_eq!(m.addressed_bots.len(), 1, "ingest keeps the routing tag");
812 }
813 _ => panic!("expected a new message"),
814 }
815 }
816
817 #[test]
818 fn reaction_cross_channel_is_rejected() {
819 use crate::stored_event::event_kind;
822 use crate::community::envelope::{build_inner_typed, seal_with_signed_inner};
823 let mut state = ChatState::new();
824 let alice = Keys::generate();
825 let bob = Keys::generate();
826 let chan_a = test_channel();
827 let chan_b = Channel {
828 id: ChannelId([0x55u8; 32]), key: ChannelKey([0x66u8; 32]), epoch: Epoch(0),
829 name: "b".into(), banned: Vec::new(), protected: Vec::new(),
830 roster: Default::default(), epoch_keys: Vec::new(), dissolved: false,
831 };
832 let target = ingest_msg_in(&mut state, &chan_a, &alice, "hi", 1, &bob);
834 let inner = build_inner_typed(
836 bob.public_key(), &chan_b.id, chan_b.epoch, event_kind::COMMUNITY_REACTION, "🔥", 2, Some(&target), &[],
837 ).finalize(&bob).unwrap();
838 let outer = seal_with_signed_inner(&Keys::generate(), &inner, &chan_b.key, &chan_b.id, chan_b.epoch).unwrap();
839 assert!(
841 process_incoming(&mut state, &outer, &chan_b, &bob.public_key()).is_none(),
842 "a reaction sealed under another channel must not apply to this channel's message"
843 );
844 let (_c, msg) = state.find_message(&target).unwrap();
845 assert!(msg.reactions.is_empty(), "cross-channel reaction must not be applied");
846 }
847
848 #[test]
849 fn inbound_message_carries_multi_attachments() {
850 use crate::stored_event::event_kind;
851 use crate::community::attachments::attachment_to_imeta;
852 use crate::community::envelope::build_inner_full;
853 use crate::types::Attachment;
854 let mut state = ChatState::new();
855 let alice = Keys::generate();
856 let bob = Keys::generate();
857 let c = test_channel();
858
859 let mk = |n: &str, ext: &str| Attachment {
860 id: "x".into(), key: "0".repeat(64), nonce: format!("{:0<24}", crate::simd::hex::bytes_to_hex_string(n.as_bytes())),
861 extension: ext.into(), name: n.into(), url: format!("https://b/{n}"),
862 path: String::new(), size: 9, img_meta: None, downloading: false, downloaded: false,
863 webxdc_topic: None, group_id: None, original_hash: Some("a".repeat(64)),
864 fallback_urls: Vec::new(),
865 };
866 let imetas = vec![attachment_to_imeta(&mk("a.png", "png")), attachment_to_imeta(&mk("b.txt", "txt"))];
867 let inner = build_inner_full(
868 alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_MESSAGE,
869 "caption", 5, None, &[], &imetas,
870 ).finalize(&alice).unwrap();
871 let outer = seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap();
872
873 match process_incoming(&mut state, &outer, &c, &bob.public_key()) {
874 Some(IncomingEvent::NewMessage(m)) => {
875 assert_eq!(m.content, "caption", "caption + attachments coexist in one event");
876 assert_eq!(m.attachments.len(), 2);
877 assert_eq!(m.attachments[0].name, "a.png");
878 assert_eq!(m.attachments[1].name, "b.txt");
879 assert!(m.attachments.iter().all(|a| a.group_id.is_none()));
880 }
881 _ => panic!("expected new message with attachments"),
882 }
883 }
884
885 #[test]
886 fn inbound_edit_only_honored_from_original_author() {
887 use crate::stored_event::event_kind;
888 let mut state = ChatState::new();
889 let alice = Keys::generate();
890 let target = ingest_msg(&mut state, &alice, "original", 1, &alice);
891
892 let edit = seal_typed(&alice, event_kind::COMMUNITY_EDIT, "edited!", 2, &target);
894 match process_incoming(&mut state, &edit, &test_channel(), &alice.public_key()) {
895 Some(IncomingEvent::Updated { message, edit_event, .. }) => {
896 assert_eq!(message.content, "edited!");
897 assert!(message.edited);
898 let ev = edit_event.expect("edit surfaces a MESSAGE_EDIT event to persist");
900 assert_eq!(ev.kind, event_kind::MESSAGE_EDIT);
901 assert_eq!(ev.reference_id.as_deref(), Some(target.as_str()));
902 assert_eq!(ev.content, "edited!");
903 }
904 _ => panic!("expected an edit update"),
905 }
906
907 let mallory = Keys::generate();
909 let hijack = seal_typed(&mallory, event_kind::COMMUNITY_EDIT, "hijacked", 3, &target);
910 assert!(process_incoming(&mut state, &hijack, &test_channel(), &alice.public_key()).is_none());
911 assert_eq!(state.find_message(&target).unwrap().1.content, "edited!");
912 }
913
914 #[test]
915 fn cooperative_delete_only_honored_from_original_author() {
916 use crate::stored_event::event_kind;
917 let mut state = ChatState::new();
918 let alice = Keys::generate();
919 let mallory = Keys::generate();
920 let target = ingest_msg(&mut state, &alice, "secret", 1, &alice);
921
922 let hijack = seal_typed(&mallory, event_kind::COMMUNITY_DELETE, "", 2, &target);
924 assert!(process_incoming(&mut state, &hijack, &test_channel(), &alice.public_key()).is_none());
925 assert!(state.find_message(&target).is_some(), "non-author delete must not remove");
926
927 let del = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 3, &target);
929 match process_incoming(&mut state, &del, &test_channel(), &alice.public_key()) {
930 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
931 _ => panic!("expected a removal"),
932 }
933 assert!(state.find_message(&target).is_none(), "message gone after author delete");
934
935 let replay = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 4, &target);
937 assert!(process_incoming(&mut state, &replay, &test_channel(), &alice.public_key()).is_none());
938 }
939
940 #[test]
941 fn dissolved_community_still_honors_an_own_message_delete() {
942 use crate::stored_event::event_kind;
943 let mut state = ChatState::new();
944 let alice = Keys::generate();
945 let target = ingest_msg(&mut state, &alice, "alice's own message", 1, &alice);
946 let mut ch = test_channel();
947 ch.dissolved = true;
948 let del = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 2, &target);
951 match process_incoming(&mut state, &del, &ch, &alice.public_key()) {
952 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
953 _ => panic!("a self-delete must be honored in a dissolved community"),
954 }
955 assert!(state.find_message(&target).is_none(), "own message scrubbed from the dead community");
956 }
957
958 #[test]
959 fn admin_moderation_hide_removes_any_message() {
960 let owner = Keys::generate();
961 let admin = Keys::generate(); let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
963 let alice = Keys::generate(); let mallory = Keys::generate(); let mut state = ChatState::new();
966 let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
967
968 let hijack = seal_hide(&c, &mallory, &target, 2, None);
970 assert!(process_incoming(&mut state, &hijack, &c, &alice.public_key()).is_none());
971 assert!(state.find_message(&target).is_some(), "unprivileged hide rejected");
972
973 let hide = seal_hide(&c, &admin, &target, 3, Some(&cite));
975 match process_incoming(&mut state, &hide, &c, &alice.public_key()) {
976 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
977 _ => panic!("expected admin moderation-hide to remove the message"),
978 }
979 assert!(state.find_message(&target).is_none(), "admin hide removed the message");
980 }
981
982 #[test]
983 fn admin_hide_without_a_citation_is_dropped() {
984 let owner = Keys::generate();
988 let admin = Keys::generate();
989 let (_tmp, _guard, c, _cite) = db_roster_channel(&owner, &admin.public_key());
990 let alice = Keys::generate();
991 let mut state = ChatState::new();
992 let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
993
994 let hide = seal_hide(&c, &admin, &target, 2, None); assert!(process_incoming(&mut state, &hide, &c, &alice.public_key()).is_none());
996 assert!(state.find_message(&target).is_some(), "an uncited admin hide is dropped");
997 }
998
999 #[test]
1000 fn hide_citing_an_unsynced_grant_version_is_dropped() {
1001 let owner = Keys::generate();
1005 let admin = Keys::generate();
1006 let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1007 let alice = Keys::generate();
1008 let mut state = ChatState::new();
1009 let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
1010
1011 let ahead = AuthorityCitation { version: 2, ..cite };
1013 let hide = seal_hide(&c, &admin, &target, 2, Some(&ahead));
1014 assert!(process_incoming(&mut state, &hide, &c, &alice.public_key()).is_none());
1015 assert!(state.find_message(&target).is_some(), "a hide citing an unsynced version is dropped");
1016 }
1017
1018 #[test]
1019 fn hide_with_a_forged_citation_hash_is_dropped() {
1020 let owner = Keys::generate();
1022 let admin = Keys::generate();
1023 let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1024 let alice = Keys::generate();
1025 let mut state = ChatState::new();
1026 let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
1027
1028 let forged = AuthorityCitation { edition_hash: [0xEE; 32], ..cite };
1029 let hide = seal_hide(&c, &admin, &target, 2, Some(&forged));
1030 assert!(process_incoming(&mut state, &hide, &c, &alice.public_key()).is_none());
1031 assert!(state.find_message(&target).is_some(), "a forged-hash citation is dropped");
1032 }
1033
1034 #[test]
1035 fn protected_owner_cannot_be_moderation_hidden_but_others_can() {
1036 let owner = Keys::generate(); let admin = Keys::generate(); let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1039 let mut state = ChatState::new();
1040
1041 let owners_msg = ingest_msg_in(&mut state, &c, &owner, "owner speaks", 1, &owner);
1043 let hide_owner = seal_hide(&c, &admin, &owners_msg, 2, Some(&cite));
1044 assert!(process_incoming(&mut state, &hide_owner, &c, &owner.public_key()).is_none());
1045 assert!(state.find_message(&owners_msg).is_some(), "owner's message is protected");
1046
1047 let member = Keys::generate();
1049 let members_msg = ingest_msg_in(&mut state, &c, &member, "member speaks", 3, &owner);
1050 let hide_member = seal_hide(&c, &admin, &members_msg, 4, Some(&cite));
1051 match process_incoming(&mut state, &hide_member, &c, &owner.public_key()) {
1052 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, members_msg),
1053 _ => panic!("a non-protected member's message should be hideable"),
1054 }
1055 }
1056
1057 #[test]
1058 fn admin_hide_of_absent_target_defers_until_resident() {
1059 let owner = Keys::generate();
1067 let admin = Keys::generate();
1068 let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1069 let mut state = ChatState::new();
1070 let absent_target = "f".repeat(64); let hide = seal_hide(&c, &admin, &absent_target, 1, Some(&cite));
1073 assert!(
1074 process_incoming(&mut state, &hide, &c, &Keys::generate().public_key()).is_none(),
1075 "a hide of an absent target defers (None) rather than falsely tombstoning + self-deduping",
1076 );
1077
1078 let mallory = Keys::generate();
1080 let hijack = seal_hide(&c, &mallory, &absent_target, 2, None);
1081 assert!(process_incoming(&mut state, &hijack, &c, &mallory.public_key()).is_none());
1082
1083 let uncited = seal_hide(&c, &admin, &absent_target, 3, None);
1086 assert!(
1087 process_incoming(&mut state, &uncited, &c, &Keys::generate().public_key()).is_none(),
1088 "an admin's uncited hide of an unknown target is dropped (pinned gates the author-unknown path)"
1089 );
1090 }
1091
1092 #[tokio::test]
1093 async fn out_of_window_hide_authorizes_against_db_author() {
1094 use crate::types::Message;
1095 use nostr_sdk::prelude::ToBech32;
1099 let owner = Keys::generate();
1100 let admin = Keys::generate(); let member = Keys::generate();
1102 let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1103
1104 let owner_msg = "a".repeat(64);
1106 let member_msg = "b".repeat(64);
1107 let mk = |id: &str, author: &Keys, at: u64| {
1108 let mut m = Message::default();
1109 m.id = id.to_string();
1110 m.npub = Some(author.public_key().to_bech32().unwrap());
1111 m.at = at;
1112 m
1113 };
1114 crate::db::events::save_message("chatoow", &mk(&owner_msg, &owner, 1)).await.unwrap();
1115 crate::db::events::save_message("chatoow", &mk(&member_msg, &member, 2)).await.unwrap();
1116
1117 let mut state = ChatState::new();
1118 let hide_owner = seal_hide(&c, &admin, &owner_msg, 3, Some(&cite));
1120 assert!(
1121 process_incoming(&mut state, &hide_owner, &c, &member.public_key()).is_none(),
1122 "owner's paged-out message must not be hideable by an admin"
1123 );
1124 let hide_member = seal_hide(&c, &admin, &member_msg, 4, Some(&cite));
1126 match process_incoming(&mut state, &hide_member, &c, &owner.public_key()) {
1127 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, member_msg),
1128 _ => panic!("admin should hide a member's paged-out message"),
1129 }
1130
1131 let member_msg2 = "c".repeat(64);
1135 crate::db::events::save_message("chatoow", &mk(&member_msg2, &member, 5)).await.unwrap();
1136 let mut sealed = c.clone();
1137 sealed.dissolved = true;
1138 let hide_sealed = seal_hide(&sealed, &admin, &member_msg2, 6, Some(&cite));
1139 assert!(
1140 process_incoming(&mut state, &hide_sealed, &sealed, &owner.public_key()).is_none(),
1141 "a dissolved community accepts no moderation-hide, resident or paged-out"
1142 );
1143 let self_del = seal_hide(&sealed, &member, &member_msg2, 7, None);
1144 match process_incoming(&mut state, &self_del, &sealed, &owner.public_key()) {
1145 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, member_msg2),
1146 _ => panic!("a self-delete of a paged-out message must survive the dissolved seal"),
1147 }
1148 crate::db::close_database();
1149 }
1150
1151 fn seal_kick(channel: &Channel, author: &Keys, target_hex: &str, ms: u64, citation: Option<&AuthorityCitation>) -> Event {
1153 let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
1154 let inner = build_inner_full(
1155 author.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_KICK, target_hex, ms, None, &[], &extra,
1156 )
1157 .finalize(author)
1158 .unwrap();
1159 seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
1160 }
1161
1162 fn post_join_ms() -> u64 {
1165 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
1166 (now + 5) * 1000
1167 }
1168
1169 #[test]
1170 fn cited_admin_kick_of_local_user_yields_self_removal() {
1171 let owner = Keys::generate();
1172 let admin = Keys::generate();
1173 let member = Keys::generate();
1174 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1175 let mut state = ChatState::new();
1176 let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), post_join_ms(), Some(&cite));
1178 match process_incoming(&mut state, &kick, &channel, &member.public_key()) {
1179 Some(IncomingEvent::Kicked { community_id }) => assert!(!community_id.is_empty()),
1180 _ => panic!("expected Kicked"),
1181 }
1182 crate::db::close_database();
1183 }
1184
1185 #[test]
1186 fn cited_admin_kick_of_other_member_is_a_leave() {
1187 use nostr_sdk::prelude::ToBech32;
1188 let owner = Keys::generate();
1189 let admin = Keys::generate();
1190 let member = Keys::generate();
1191 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1192 let mut state = ChatState::new();
1193 let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), post_join_ms(), Some(&cite));
1196 match process_incoming(&mut state, &kick, &channel, &owner.public_key()) {
1197 Some(IncomingEvent::Presence { npub, joined, .. }) => {
1198 assert!(!joined);
1199 assert_eq!(npub, member.public_key().to_bech32().unwrap());
1200 }
1201 _ => panic!("expected leave Presence"),
1202 }
1203 crate::db::close_database();
1204 }
1205
1206 #[test]
1207 fn uncited_kick_is_dropped() {
1208 let owner = Keys::generate();
1209 let admin = Keys::generate();
1210 let member = Keys::generate();
1211 let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1212 let mut state = ChatState::new();
1213 let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), 1, None);
1214 assert!(process_incoming(&mut state, &kick, &channel, &member.public_key()).is_none(),
1215 "a non-owner kick without a citation is dropped");
1216 crate::db::close_database();
1217 }
1218
1219 #[test]
1220 fn unprivileged_kick_is_dropped() {
1221 let owner = Keys::generate();
1222 let admin = Keys::generate();
1223 let mallory = Keys::generate();
1224 let member = Keys::generate();
1225 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1226 let mut state = ChatState::new();
1227 let kick = seal_kick(&channel, &mallory, &member.public_key().to_hex(), 1, Some(&cite));
1230 assert!(process_incoming(&mut state, &kick, &channel, &member.public_key()).is_none(),
1231 "a kick from an unranked actor is dropped");
1232 crate::db::close_database();
1233 }
1234
1235 #[test]
1236 fn kick_of_owner_is_dropped() {
1237 let owner = Keys::generate();
1238 let admin = Keys::generate();
1239 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1240 let mut state = ChatState::new();
1241 let kick = seal_kick(&channel, &admin, &owner.public_key().to_hex(), post_join_ms(), Some(&cite));
1243 assert!(process_incoming(&mut state, &kick, &channel, &owner.public_key()).is_none(),
1244 "an admin cannot kick the owner");
1245 crate::db::close_database();
1246 }
1247
1248 #[test]
1249 fn stale_kick_predating_join_is_dropped() {
1250 let owner = Keys::generate();
1251 let admin = Keys::generate();
1252 let member = Keys::generate();
1253 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1254 let mut state = ChatState::new();
1255 let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), 1, Some(&cite));
1258 assert!(process_incoming(&mut state, &kick, &channel, &member.public_key()).is_none(),
1259 "a kick older than the current join is dropped");
1260 crate::db::close_database();
1261 }
1262
1263 #[test]
1264 fn webxdc_signals_parse_ad_and_left_and_reject_garbage() {
1265 use crate::stored_event::event_kind;
1266 use nostr_sdk::prelude::ToBech32;
1267 let mut state = ChatState::new();
1268 let alice = Keys::generate();
1269 let c = test_channel();
1270 let viewer = Keys::generate();
1271 let mk = |content: &str, ms: u64| {
1272 let inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_WEBXDC, content, ms, None, &[])
1273 .finalize(&alice).unwrap();
1274 seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
1275 };
1276 let topic = crate::webxdc::mint_topic_id("game-hash", "sender");
1277
1278 let ad = serde_json::json!({ "op": "ad", "topic": topic, "addr": "BASE32NODEADDR" }).to_string();
1280 match process_incoming(&mut state, &mk(&ad, 1), &c, &viewer.public_key()) {
1281 Some(IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, .. }) => {
1282 assert_eq!(npub, alice.public_key().to_bech32().unwrap(), "player is the inner author");
1283 assert_eq!(topic_id, topic);
1284 assert_eq!(node_addr.as_deref(), Some("BASE32NODEADDR"));
1285 }
1286 _ => panic!("expected a webxdc advertisement"),
1287 }
1288
1289 let left = serde_json::json!({ "op": "left", "topic": topic }).to_string();
1291 match process_incoming(&mut state, &mk(&left, 2), &c, &viewer.public_key()) {
1292 Some(IncomingEvent::WebxdcPeer { node_addr, .. }) => {
1293 assert!(node_addr.is_none(), "peer-left carries no addr");
1294 }
1295 _ => panic!("expected a webxdc peer-left"),
1296 }
1297
1298 assert!(
1300 process_incoming(&mut state, &mk(&ad, 3), &c, &alice.public_key()).is_none(),
1301 "own webxdc signal must be ignored"
1302 );
1303
1304 let bad_topic = serde_json::json!({ "op": "ad", "topic": "../../etc", "addr": "X" }).to_string();
1306 assert!(process_incoming(&mut state, &mk(&bad_topic, 4), &c, &viewer.public_key()).is_none());
1307 let bad_op = serde_json::json!({ "op": "explode", "topic": topic }).to_string();
1308 assert!(process_incoming(&mut state, &mk(&bad_op, 5), &c, &viewer.public_key()).is_none());
1309 let no_addr = serde_json::json!({ "op": "ad", "topic": topic }).to_string();
1310 assert!(process_incoming(&mut state, &mk(&no_addr, 6), &c, &viewer.public_key()).is_none());
1311 assert!(process_incoming(&mut state, &mk("not json", 7), &c, &viewer.public_key()).is_none());
1312 }
1313
1314 #[test]
1315 fn typing_indicator_parses_drops_own_echo_and_rejects_garbage() {
1316 use crate::stored_event::event_kind;
1317 use nostr_sdk::prelude::ToBech32;
1318 let mut state = ChatState::new();
1319 let alice = Keys::generate();
1320 let c = test_channel();
1321 let viewer = Keys::generate();
1322 let mk = |content: &str, ms: u64| {
1323 let inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_TYPING, content, ms, None, &[])
1324 .finalize(&alice).unwrap();
1325 seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
1326 };
1327 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
1328
1329 match process_incoming(&mut state, &mk("typing", 1), &c, &viewer.public_key()) {
1332 Some(IncomingEvent::Typing { npub, until }) => {
1333 assert_eq!(npub, alice.public_key().to_bech32().unwrap(), "typer is the inner author");
1334 assert!(until >= now && until <= now + 31, "until is receiver-computed (~now + 30s)");
1335 }
1336 _ => panic!("expected a typing indicator"),
1337 }
1338
1339 assert!(
1341 process_incoming(&mut state, &mk("typing", 2), &c, &alice.public_key()).is_none(),
1342 "own typing signal must be ignored"
1343 );
1344
1345 assert!(process_incoming(&mut state, &mk("nope", 3), &c, &viewer.public_key()).is_none());
1347 }
1348
1349 #[test]
1350 fn presence_announcements_parse_join_and_leave() {
1351 use crate::stored_event::event_kind;
1352 let mut state = ChatState::new();
1353 let alice = Keys::generate();
1354 let c = test_channel();
1355 let viewer = Keys::generate();
1356 let mk = |content: &str, ms: u64| {
1357 let inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_PRESENCE, content, ms, None, &[])
1358 .finalize(&alice).unwrap();
1359 seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
1360 };
1361 match process_incoming(&mut state, &mk("join", 1), &c, &viewer.public_key()) {
1362 Some(IncomingEvent::Presence { npub, joined, .. }) => {
1363 assert!(joined, "content 'join' → joined");
1364 assert_eq!(npub, alice.public_key().to_bech32().unwrap(), "announcer is the inner author");
1365 }
1366 _ => panic!("expected a join presence"),
1367 }
1368 match process_incoming(&mut state, &mk("leave", 2), &c, &viewer.public_key()) {
1369 Some(IncomingEvent::Presence { joined, invited_by, .. }) => {
1370 assert!(!joined, "content 'leave' → not joined");
1371 assert!(invited_by.is_none(), "a plain leave carries no attribution");
1372 }
1373 _ => panic!("expected a leave presence"),
1374 }
1375 let jean = Keys::generate().public_key().to_bech32().unwrap();
1378 let attributed = serde_json::json!({ "by": jean, "l": "Reddit" }).to_string();
1379 match process_incoming(&mut state, &mk(&attributed, 3), &c, &viewer.public_key()) {
1380 Some(IncomingEvent::Presence { joined, invited_by, invited_label, .. }) => {
1381 assert!(joined, "an attributed-join JSON is still a join");
1382 assert_eq!(invited_by.as_deref(), Some(jean.as_str()), "valid inviter npub surfaced");
1383 assert_eq!(invited_label.as_deref(), Some("Reddit"), "link label surfaced");
1384 }
1385 _ => panic!("expected an attributed join presence"),
1386 }
1387 let forged = serde_json::json!({ "by": "haha not an npub", "l": "x" }).to_string();
1389 match process_incoming(&mut state, &mk(&forged, 4), &c, &viewer.public_key()) {
1390 Some(IncomingEvent::Presence { invited_by, .. }) => assert!(invited_by.is_none(), "forged inviter dropped"),
1391 _ => panic!("expected a join presence"),
1392 }
1393 }
1394
1395 #[test]
1396 fn leave_presence_authored_by_local_npub_yields_self_left() {
1397 use crate::stored_event::event_kind;
1398 let owner = Keys::generate();
1401 let admin = Keys::generate();
1402 let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1403 let mut state = ChatState::new();
1404 let cid = crate::db::community::community_id_for_channel(&channel.id.to_hex()).unwrap().unwrap();
1407 let cid_bytes = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid));
1408 let leave_ms = crate::db::community::community_created_at_ms(&cid_bytes).unwrap_or(0) + 10_000;
1409 let leave = {
1410 let inner = build_inner_typed(owner.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_PRESENCE, "leave", leave_ms, None, &[])
1411 .finalize(&owner).unwrap();
1412 seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
1413 };
1414 match process_incoming(&mut state, &leave, &channel, &owner.public_key()) {
1415 Some(IncomingEvent::SelfLeft { community_id }) => assert!(!community_id.is_empty()),
1416 _ => panic!("expected SelfLeft"),
1417 }
1418 crate::db::close_database();
1419 }
1420
1421 #[test]
1422 fn leave_presence_authored_by_another_npub_stays_a_plain_leave() {
1423 use crate::stored_event::event_kind;
1424 use nostr_sdk::prelude::ToBech32;
1425 let owner = Keys::generate();
1427 let admin = Keys::generate();
1428 let other = Keys::generate();
1429 let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1430 let mut state = ChatState::new();
1431 let leave = {
1432 let inner = build_inner_typed(other.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_PRESENCE, "leave", 2, None, &[])
1433 .finalize(&other).unwrap();
1434 seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
1435 };
1436 match process_incoming(&mut state, &leave, &channel, &owner.public_key()) {
1438 Some(IncomingEvent::Presence { npub, joined, .. }) => {
1439 assert!(!joined);
1440 assert_eq!(npub, other.public_key().to_bech32().unwrap());
1441 }
1442 _ => panic!("expected plain leave Presence"),
1443 }
1444 crate::db::close_database();
1445 }
1446
1447 #[test]
1448 fn self_delete_still_applies_after_keep_keys_teardown() {
1449 let owner = Keys::generate();
1452 let admin = Keys::generate();
1453 let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1454 let cid = crate::db::community::community_id_for_channel(&channel.id.to_hex()).unwrap().unwrap();
1455 let chan_hex = channel.id.to_hex();
1456 let epoch = channel.epoch.0;
1457
1458 let mut state = ChatState::new();
1460 let target = ingest_msg_in(&mut state, &channel, &owner, "mine", 1, &owner);
1461
1462 crate::db::community::delete_community_retain_keys(&cid).unwrap();
1464 let retained = crate::db::community::held_epoch_key(&cid, &chan_hex, epoch).unwrap()
1465 .expect("epoch key retained after keep-keys teardown");
1466 let mut rebuilt = channel.clone();
1467 rebuilt.key = ChannelKey(retained);
1468 rebuilt.epoch = Epoch(epoch);
1469
1470 let del = seal_hide(&rebuilt, &owner, &target, 2, None);
1472 match process_incoming(&mut state, &del, &rebuilt, &owner.public_key()) {
1473 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
1474 _ => panic!("expected the self-delete to apply under the retained key"),
1475 }
1476 crate::db::close_database();
1477 }
1478
1479 #[test]
1480 fn banned_author_events_are_dropped_including_presence() {
1481 use crate::stored_event::event_kind;
1482 let mut state = ChatState::new();
1483 let alice = Keys::generate(); let bob = Keys::generate();
1485 let mut c = test_channel();
1486 c.banned = vec![alice.public_key()];
1487
1488 let spam = seal_message(&alice, &c.key, &c.id, c.epoch, "spam", 1).unwrap();
1490 assert!(process_incoming(&mut state, &spam, &c, &bob.public_key()).is_none(), "banned message dropped");
1491
1492 let pres_inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_PRESENCE, "join", 2, None, &[])
1494 .finalize(&alice).unwrap();
1495 let pres = seal_with_signed_inner(&Keys::generate(), &pres_inner, &c.key, &c.id, c.epoch).unwrap();
1496 assert!(process_incoming(&mut state, &pres, &c, &bob.public_key()).is_none(), "banned presence dropped");
1497
1498 let ok = seal_message(&bob, &c.key, &c.id, c.epoch, "hi", 3).unwrap();
1500 assert!(matches!(process_incoming(&mut state, &ok, &c, &bob.public_key()), Some(IncomingEvent::NewMessage(_))), "non-banned applied");
1501 }
1502
1503 #[test]
1504 fn cooperative_delete_applies_after_message_in_batch_order() {
1505 use crate::stored_event::event_kind;
1506 let mut state = ChatState::new();
1507 let alice = Keys::generate();
1508 let c = test_channel();
1509
1510 let msg_outer = seal_message(&alice, &c.key, &c.id, c.epoch, "bye", 1).unwrap();
1514 let opened = open_message(&msg_outer, &c.key, &c.id, c.epoch).unwrap();
1515 let inner_id = opened.message_id.to_hex();
1516 let del_outer = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 2, &inner_id);
1517
1518 let applied = process_channel_batch(&mut state, &[del_outer, msg_outer], &c, &alice.public_key());
1519 assert!(applied.iter().any(|e| matches!(e, IncomingEvent::NewMessage(_))));
1520 assert!(applied.iter().any(|e| matches!(e, IncomingEvent::Removed { .. })));
1521 assert!(state.find_message(&inner_id).is_none(), "delete applied despite arriving first");
1522 }
1523
1524 #[test]
1525 fn build_message_sets_mine_and_author() {
1526 let me = Keys::generate();
1527 let opened = opened_from(&me, "hello", 4242);
1528 let msg = build_message(&opened, &me.public_key());
1529 assert_eq!(msg.content, "hello");
1530 assert_eq!(msg.at, 4242);
1531 assert!(msg.mine, "author == me → mine");
1532 assert_eq!(msg.npub, me.public_key().to_bech32().ok());
1533 assert_eq!(msg.id, opened.message_id.to_hex());
1534
1535 let other_view = build_message(&opened, &Keys::generate().public_key());
1537 assert!(!other_view.mine);
1538 }
1539
1540 #[test]
1541 fn ingest_creates_community_chat_and_adds_message() {
1542 let mut state = ChatState::new();
1543 let alice = Keys::generate();
1544 let opened = opened_from(&alice, "gm", 1);
1545
1546 assert!(ingest_message(&mut state, &opened, &alice.public_key()).is_some());
1547 let chat = state.chats.iter().find(|c| c.id == opened.channel_id.to_hex()).expect("chat");
1549 assert!(chat.is_community(), "channel chat must be ChatType::Community");
1550 }
1551
1552 #[test]
1553 fn process_incoming_ingests_valid_drops_foreign() {
1554 let mut state = ChatState::new();
1555 let alice = Keys::generate();
1556 let key = ChannelKey([0x33u8; 32]);
1557 let chan = ChannelId([0x44u8; 32]);
1558 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 };
1559
1560 let outer = seal_message(&alice, &key, &chan, Epoch(0), "real", 1).unwrap();
1562 assert!(process_incoming(&mut state, &outer, &channel, &alice.public_key()).is_some());
1563 assert!(state.chats.iter().any(|c| c.is_community()));
1564
1565 let other_key = ChannelKey([0x99u8; 32]);
1567 let other_chan = ChannelId([0xaau8; 32]);
1568 let foreign = seal_message(&alice, &other_key, &other_chan, Epoch(0), "nope", 1).unwrap();
1569 assert!(process_incoming(&mut state, &foreign, &channel, &alice.public_key()).is_none());
1570 assert_eq!(state.chats.iter().filter(|c| c.is_community()).count(), 1);
1571 }
1572
1573 #[test]
1574 fn ingest_dedups_on_message_id() {
1575 let mut state = ChatState::new();
1576 let alice = Keys::generate();
1577 let opened = opened_from(&alice, "once", 1);
1578
1579 assert!(ingest_message(&mut state, &opened, &alice.public_key()).is_some(), "first add");
1580 assert!(ingest_message(&mut state, &opened, &alice.public_key()).is_none(), "duplicate not re-added");
1581 assert_eq!(state.chats.iter().filter(|c| c.is_community()).count(), 1);
1583 }
1584
1585 #[test]
1586 fn dedup_keys_on_inner_id_across_distinct_outer_events() {
1587 let mut state = ChatState::new();
1593 let alice = Keys::generate();
1594 let key = ChannelKey([0x33u8; 32]);
1595 let chan = ChannelId([0x44u8; 32]);
1596 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 };
1597
1598 let outer_a = seal_message(&alice, &key, &chan, Epoch(0), "dup", 7).unwrap();
1599 let outer_b = seal_message(&alice, &key, &chan, Epoch(0), "dup", 7).unwrap();
1600 assert_ne!(outer_a.id, outer_b.id, "distinct outer events (fresh ephemeral + nonce)");
1601
1602 assert!(process_incoming(&mut state, &outer_a, &channel, &alice.public_key()).is_some());
1603 assert!(
1604 process_incoming(&mut state, &outer_b, &channel, &alice.public_key()).is_none(),
1605 "same inner message id must dedup despite a different outer event"
1606 );
1607 }
1608
1609 #[test]
1610 fn route_incoming_routes_by_pseudonym() {
1611 let mut state = ChatState::new();
1612 let alice = Keys::generate();
1613 let key = ChannelKey([0x33u8; 32]);
1614 let chan = ChannelId([0x44u8; 32]);
1615 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 };
1616
1617 let mut routes = HashMap::new();
1619 routes.insert(channel_pseudonym(&key, &chan, Epoch(0)).to_hex(), channel.clone());
1620
1621 let outer = seal_message(&alice, &key, &chan, Epoch(0), "routed", 1).unwrap();
1623 assert!(route_incoming(&mut state, &outer, &routes, &alice.public_key()).is_some());
1624
1625 let other_key = ChannelKey([0x55u8; 32]);
1627 let other_chan = ChannelId([0x66u8; 32]);
1628 let unrouted = seal_message(&alice, &other_key, &other_chan, Epoch(0), "x", 1).unwrap();
1629 assert!(route_incoming(&mut state, &unrouted, &routes, &alice.public_key()).is_none());
1630 }
1631
1632 #[test]
1633 fn ms_none_falls_back_to_created_at() {
1634 use nostr_sdk::prelude::{EventId, Timestamp, Tags};
1636 let author = Keys::generate();
1637 let opened = OpenedMessage {
1638 message_id: EventId::all_zeros(),
1639 author: author.public_key(),
1640 content: "no ms".into(),
1641 channel_id: ChannelId([1u8; 32]),
1642 epoch: Epoch(0),
1643 ms: None,
1644 created_at: Timestamp::from_secs(1500),
1645 kind: 3300,
1646 attachments: vec![],
1647 citation: None,
1648 wrapper_id: EventId::all_zeros(),
1649 tags: Tags::new(),
1650 };
1651 assert_eq!(build_message(&opened, &author.public_key()).at, 1_500_000);
1652 }
1653}