1use nostr_sdk::prelude::{Event, PublicKey};
7use nostr_sdk::ToBech32;
8
9use super::envelope::{open_message_multi, OpenedMessage};
10use super::Channel;
11use crate::state::ChatState;
12use crate::stored_event::event_kind;
13use crate::types::Message;
14
15fn concord_rumor(
27 opened: &OpenedMessage,
28 kind: nostr_sdk::Kind,
29 my_pubkey: &PublicKey,
30) -> (crate::rumor::RumorEvent, crate::rumor::RumorContext) {
31 use crate::rumor::{ConversationType, RumorContext, RumorEvent};
32 (
33 RumorEvent {
34 id: opened.message_id,
35 kind,
36 content: opened.content.clone(),
37 tags: opened.tags.clone(),
38 created_at: opened.created_at,
39 pubkey: opened.author,
40 },
41 RumorContext {
42 sender: opened.author,
43 is_mine: opened.author == *my_pubkey,
44 conversation_id: opened.channel_id.to_hex(),
45 conversation_type: ConversationType::Community,
46 },
47 )
48}
49
50pub fn build_message(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Message {
51 use crate::rumor::{process_rumor, RumorProcessingResult};
52 let (rumor, ctx) = concord_rumor(opened, nostr_sdk::Kind::PrivateDirectMessage, my_pubkey);
53 let mut msg = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
54 Ok(RumorProcessingResult::TextMessage(m)) => m,
55 _ => Message {
58 id: opened.message_id.to_hex(),
59 content: opened.content.clone(),
60 at: opened.ms.unwrap_or_else(|| opened.created_at.as_secs().saturating_mul(1000)),
61 mine: opened.author == *my_pubkey,
62 npub: opened.author.to_bech32().ok(),
63 ..Default::default()
64 },
65 };
66 msg.attachments = opened.attachments.clone();
69 msg.wrapper_event_id = Some(opened.wrapper_id.to_hex());
70 msg
71}
72
73pub fn ingest_message(
78 state: &mut ChatState,
79 opened: &OpenedMessage,
80 my_pubkey: &PublicKey,
81) -> Option<Message> {
82 let chat_id = opened.channel_id.to_hex();
83 let msg = build_message(opened, my_pubkey);
84 if crate::db::events::event_exists(&msg.id).unwrap_or(false) {
91 return None;
92 }
93 state.ensure_community_chat(&chat_id);
94 if state.add_message_to_chat(&chat_id, msg.clone()) {
95 Some(msg)
96 } else {
97 None
98 }
99}
100
101pub enum IncomingEvent {
106 NewMessage(Message),
107 Updated { target_id: String, message: Message, edit_event: Option<Box<crate::stored_event::StoredEvent>> },
112 Removed { target_id: String },
113 ReactionRemoved { message_id: String, reaction_id: String, message: Message },
118 Presence { npub: String, joined: bool, event_id: String, created_at: u64, invited_by: Option<String>, invited_label: Option<String> },
125 Kicked { community_id: String },
130 SelfLeft { community_id: String },
136 WebxdcPeer {
141 npub: String,
142 topic_id: String,
143 node_addr: Option<String>,
145 event_id: String,
146 created_at: u64,
147 },
148 Typing { npub: String, until: u64 },
152}
153
154pub fn process_incoming(
160 state: &mut ChatState,
161 event: &Event,
162 channel: &Channel,
163 my_pubkey: &PublicKey,
164) -> Option<IncomingEvent> {
165 let outer_bytes = event.id.to_bytes();
171 if crate::db::events::wrapper_event_exists(&event.id.to_hex()).unwrap_or(false)
172 || crate::db::wrappers::processed_wrapper_exists(&outer_bytes)
173 {
174 return None;
175 }
176 if channel.dissolved && event.kind.as_u16() != event_kind::COMMUNITY_DELETE {
184 return None;
185 }
186 let opened = match open_message_multi(event, &channel.id, &channel.read_epoch_keys()) {
190 Ok(o) => o,
191 Err(e) => {
192 crate::log_debug!("[community] inbound drop {}: {}", event.id.to_hex(), e);
193 return None;
194 }
195 };
196 if channel.banned.contains(&opened.author) {
199 crate::log_debug!("[community] dropped event from banned author {}", opened.author.to_hex());
200 return None;
201 }
202 let outcome = match opened.kind {
203 k if k == event_kind::COMMUNITY_MESSAGE => {
204 ingest_message(state, &opened, my_pubkey).map(IncomingEvent::NewMessage)
205 }
206 k if k == event_kind::COMMUNITY_REACTION => apply_reaction(state, &opened, my_pubkey),
207 k if k == event_kind::COMMUNITY_EDIT => apply_edit(state, &opened, my_pubkey),
208 k if k == event_kind::COMMUNITY_DELETE => apply_delete(state, &opened, channel, my_pubkey),
209 k if k == event_kind::COMMUNITY_PRESENCE => apply_presence(&opened, channel, my_pubkey),
210 k if k == event_kind::COMMUNITY_KICK => apply_kick(&opened, channel, my_pubkey),
211 k if k == event_kind::COMMUNITY_WEBXDC => apply_webxdc(&opened, my_pubkey),
212 k if k == event_kind::COMMUNITY_TYPING => apply_typing(&opened, my_pubkey),
213 _ => None,
214 };
215 if let Some(ref evt) = outcome {
221 if !matches!(evt, IncomingEvent::NewMessage(_) | IncomingEvent::Typing { .. }) {
222 let _ = crate::db::wrappers::save_processed_wrapper(
223 &outer_bytes, event.created_at.as_secs(), crate::db::wrappers::TRANSPORT_CONCORD,
224 );
225 }
226 }
227 outcome
228}
229
230fn apply_presence(opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
234 let joined = opened.content != "leave";
236 if !joined && opened.author == *my_pubkey {
240 if let Ok(Some(cid)) = crate::db::community::community_id_for_channel(&channel.id.to_hex()) {
241 let cid_bytes = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid));
249 let join_ms = crate::db::community::community_created_at_ms(&cid_bytes).unwrap_or(0);
250 if opened.created_at.as_secs().saturating_mul(1000) > join_ms {
251 return Some(IncomingEvent::SelfLeft { community_id: cid });
252 }
253 crate::log_debug!("[community] self-leave predates this join — rendering as history, not teardown");
254 }
255 }
256 let (invited_by, invited_label) = if joined {
257 serde_json::from_str::<serde_json::Value>(&opened.content)
258 .ok()
259 .map(|v| {
260 let by = v.get("by").and_then(|b| b.as_str())
264 .filter(|s| PublicKey::parse(s).is_ok())
265 .map(str::to_string);
266 let label = v.get("l").and_then(|l| l.as_str())
267 .map(|s| s.chars().take(48).collect::<String>())
268 .filter(|s| !s.is_empty());
269 (by, label)
270 })
271 .unwrap_or((None, None))
272 } else {
273 (None, None)
274 };
275 Some(IncomingEvent::Presence {
276 npub: opened.author.to_bech32().ok()?,
277 joined,
278 event_id: opened.message_id.to_hex(),
279 created_at: clamp_inner_secs(opened.created_at.as_secs()),
280 invited_by,
281 invited_label,
282 })
283}
284
285fn clamp_inner_secs(secs: u64) -> u64 {
289 let now = std::time::SystemTime::now()
290 .duration_since(std::time::UNIX_EPOCH)
291 .map(|d| d.as_secs())
292 .unwrap_or(0);
293 secs.min(now + 300)
294}
295
296fn apply_webxdc(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
302 if opened.author == *my_pubkey {
303 return None;
304 }
305 let (topic_id, node_addr) = crate::webxdc::parse_peer_signal(&opened.content)?;
306 Some(IncomingEvent::WebxdcPeer {
307 npub: opened.author.to_bech32().ok()?,
308 topic_id,
309 node_addr,
310 event_id: opened.message_id.to_hex(),
311 created_at: opened.created_at.as_secs(),
312 })
313}
314
315fn apply_typing(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
320 if opened.author == *my_pubkey {
321 return None;
322 }
323 if opened.content != "typing" {
324 return None;
325 }
326 let now = std::time::SystemTime::now()
327 .duration_since(std::time::UNIX_EPOCH)
328 .map(|d| d.as_secs())
329 .unwrap_or(0);
330 Some(IncomingEvent::Typing {
331 npub: opened.author.to_bech32().ok()?,
332 until: now + 30,
333 })
334}
335
336fn apply_kick(opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
347 use crate::community::roles::Permissions;
348 let target = PublicKey::parse(opened.content.trim()).ok()?;
349 let target_hex = target.to_hex();
350 let kicker_hex = opened.author.to_hex();
351 let owner_hex = channel.protected.first().map(|pk| pk.to_hex());
352 let pinned = actor_authority_pinned(channel, owner_hex.as_deref(), &kicker_hex, opened.citation.as_ref());
353 if !(pinned && channel.roster.can_act_on_member(&kicker_hex, owner_hex.as_deref(), &target_hex, Permissions::KICK)) {
354 crate::log_debug!("[community] dropped kick: {kicker_hex} not authorized to kick {target_hex}");
355 return None;
356 }
357 let cid_hex = crate::db::community::community_id_for_channel(&channel.id.to_hex()).ok().flatten()?;
358 let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
363 let join_ms = crate::db::community::community_created_at_ms(&cid).unwrap_or(0);
364 if opened.created_at.as_secs().saturating_mul(1000) <= join_ms {
367 crate::log_debug!("[community] dropped stale kick of {target_hex} (predates this join)");
368 return None;
369 }
370 if target == *my_pubkey {
371 return Some(IncomingEvent::Kicked { community_id: cid_hex });
372 }
373 Some(IncomingEvent::Presence {
375 npub: target.to_bech32().ok()?,
376 joined: false,
377 event_id: opened.message_id.to_hex(),
378 created_at: clamp_inner_secs(opened.created_at.as_secs()),
379 invited_by: None,
380 invited_label: None,
381 })
382}
383
384pub fn event_authenticates(event: &Event, channel: &Channel) -> bool {
397 if crate::db::events::wrapper_event_exists(&event.id.to_hex()).unwrap_or(false)
398 || crate::db::wrappers::processed_wrapper_exists(&event.id.to_bytes())
399 {
400 return true;
401 }
402 open_message_multi(event, &channel.id, &channel.read_epoch_keys()).is_ok()
403}
404
405pub fn process_channel_batch(
419 state: &mut ChatState,
420 events: &[Event],
421 channel: &Channel,
422 my_pubkey: &PublicKey,
423) -> Vec<IncomingEvent> {
424 let mut out = Vec::new();
425 for want_message in [true, false] {
428 for ev in events {
429 let is_message = ev.kind.as_u16() == event_kind::COMMUNITY_MESSAGE;
430 if is_message != want_message {
431 continue;
432 }
433 if let Some(evt) = process_incoming(state, ev, channel, my_pubkey) {
434 out.push(evt);
435 }
436 }
437 }
438 out
439}
440
441fn apply_reaction(state: &mut ChatState, opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
445 use crate::rumor::{process_rumor, RumorProcessingResult};
446 let (rumor, ctx) = concord_rumor(opened, nostr_sdk::Kind::Reaction, my_pubkey);
447 let reaction = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
448 Ok(RumorProcessingResult::Reaction(r)) => r,
449 _ => return None,
450 };
451 let target_id = reaction.reference_id.clone();
452 let expected_chat = opened.channel_id.to_hex();
457 if !matches!(state.find_message(&target_id), Some((chat, _)) if chat.id == expected_chat) {
458 return None;
459 }
460 let (_chat_id, was_added) = state.add_reaction_to_message(&target_id, reaction)?;
461 if !was_added {
462 return None;
463 }
464 let (_chat, message) = state.find_message(&target_id)?;
465 Some(IncomingEvent::Updated { target_id, message, edit_event: None })
466}
467
468fn apply_edit(state: &mut ChatState, opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
472 use crate::rumor::{process_rumor, RumorProcessingResult};
473 let (rumor, ctx) = concord_rumor(opened, nostr_sdk::Kind::from(event_kind::MESSAGE_EDIT), my_pubkey);
474 let (target_id, new_content, edited_at, emoji_tags, edit_event) = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
475 Ok(RumorProcessingResult::Edit { message_id, new_content, edited_at, emoji_tags, event }) => (message_id, new_content, edited_at, emoji_tags, event),
476 _ => return None,
477 };
478 let editor_npub = opened.author.to_bech32().ok()?;
481 let target_author = state.find_message(&target_id).and_then(|(_, m)| m.npub)?;
482 if target_author != editor_npub {
483 crate::log_debug!("[community] dropped edit from non-author of {}", target_id);
484 return None;
485 }
486 let (_chat_id, message) = state.update_message(&target_id, |m| {
489 m.apply_edit(new_content.clone(), edited_at, emoji_tags.clone());
490 })?;
491 Some(IncomingEvent::Updated { target_id, message, edit_event: Some(Box::new(edit_event)) })
494}
495
496fn actor_authority_pinned(
506 channel: &Channel,
507 owner_hex: Option<&str>,
508 actor_hex: &str,
509 citation: Option<&super::edition::AuthorityCitation>,
510) -> bool {
511 if owner_hex == Some(actor_hex) {
512 return true; }
514 if citation.is_none() {
515 return false; }
517 let Ok(Some(cid)) = crate::db::community::community_id_for_channel(&channel.id.to_hex()) else {
518 return false; };
520 let cid_bytes = crate::simd::hex::hex_to_bytes_32(&cid);
521 let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
522 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
523 &crate::community::CommunityId(cid_bytes),
524 &actor_bytes,
525 ));
526 let head: Vec<super::roster::EntityHead> = crate::db::community::get_edition_head(&cid, &grant_hex)
527 .ok()
528 .flatten()
529 .map(|(version, self_hash)| super::roster::EntityHead { entity_hex: grant_hex.clone(), version, self_hash, inner_id: [0u8; 32], citation: None })
530 .into_iter()
531 .collect();
532 super::roster::authority_citation_satisfied(&head, owner_hex, actor_hex, &grant_hex, citation)
533}
534
535fn apply_delete(state: &mut ChatState, opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
536 use crate::community::roles::Permissions;
537 use crate::rumor::{process_rumor, RumorProcessingResult};
538 let (rumor, ctx) = concord_rumor(opened, nostr_sdk::Kind::EventDeletion, my_pubkey);
542 let target_id = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
543 Ok(RumorProcessingResult::DeletionRequest { target_event_id }) => target_event_id,
544 _ => return None,
545 };
546 let deleter = opened.author;
547 let deleter_hex = deleter.to_hex();
548
549 if let Some((_chat_id, message_id, author_npub, _is_comm)) = state.find_reaction(&target_id) {
553 let reactor_ok = PublicKey::parse(&author_npub).map(|pk| pk == deleter).unwrap_or(false);
554 if !reactor_ok {
555 crate::log_debug!("[community] dropped reaction-revoke: {deleter_hex} is not the reactor of {target_id}");
556 return None;
557 }
558 return state
559 .remove_reaction_from_message(&message_id, &target_id)
560 .map(|(_cid, message)| IncomingEvent::ReactionRemoved {
561 message_id,
562 reaction_id: target_id,
563 message,
564 });
565 }
566
567 let owner_hex = channel.protected.first().map(|pk| pk.to_hex());
568 let pinned = actor_authority_pinned(channel, owner_hex.as_deref(), &deleter_hex, opened.citation.as_ref());
571
572 let target_author = state
575 .find_message(&target_id)
576 .and_then(|(_, m)| m.npub.clone())
577 .and_then(|n| PublicKey::parse(&n).ok());
578
579 if let Some(author) = target_author {
580 if author == deleter {
582 return state.remove_message(&target_id).map(|_| IncomingEvent::Removed { target_id });
583 }
584 if pinned && !channel.dissolved && channel.roster.can_act_on_member(&deleter_hex, owner_hex.as_deref(), &author.to_hex(), Permissions::MANAGE_MESSAGES) {
590 return state.remove_message(&target_id).map(|_| IncomingEvent::Removed { target_id });
591 }
592 crate::log_debug!("[community] dropped delete: {deleter_hex} not authorized to remove {target_id}");
593 return None;
594 }
595
596 if let Ok(Some(author_npub)) = crate::db::events::event_author(&target_id) {
600 if let Ok(author) = PublicKey::parse(&author_npub) {
601 let ok = author == deleter
605 || (pinned
606 && !channel.dissolved
607 && channel.roster.can_act_on_member(&deleter_hex, owner_hex.as_deref(), &author.to_hex(), Permissions::MANAGE_MESSAGES));
608 if ok {
609 return Some(IncomingEvent::Removed { target_id });
610 }
611 crate::log_debug!("[community] dropped out-of-window delete: {deleter_hex} not authorized over {target_id}");
612 return None;
613 }
614 }
615
616 None
624}
625
626pub fn route_incoming(
632 state: &mut ChatState,
633 event: &Event,
634 routes: &std::collections::HashMap<String, Channel>,
635 my_pubkey: &PublicKey,
636) -> Option<IncomingEvent> {
637 let pseudonym = event.tags.iter().find_map(|t| {
638 let s = t.as_slice();
639 (s.len() >= 2 && s[0] == "z").then(|| s[1].clone())
640 })?;
641 let channel = routes.get(&pseudonym)?;
642 process_incoming(state, event, channel, my_pubkey)
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648 use crate::community::derive::channel_pseudonym;
649 use std::collections::HashMap;
650 use crate::community::envelope::{build_inner_full, build_inner_typed, open_message, seal_message, seal_with_signed_inner};
651 use crate::community::edition::AuthorityCitation;
652 use crate::community::{Channel, ChannelId, ChannelKey, Epoch};
653 use crate::state::ChatState;
654 use nostr_sdk::prelude::{Keys, Tag};
655
656 fn db_roster_channel(
662 owner: &Keys,
663 admin: &PublicKey,
664 ) -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Channel, AuthorityCitation) {
665 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
666 use nostr_sdk::{JsonUtil, ToBech32};
667 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
668 crate::db::close_database();
669 let tmp = tempfile::tempdir().unwrap();
670 let account = owner.public_key().to_bech32().unwrap();
671 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
672 crate::db::set_app_data_dir(tmp.path().to_path_buf());
673 crate::db::set_current_account(account.clone()).unwrap();
674 crate::db::init_database(&account).unwrap();
675 crate::state::MY_SECRET_KEY.store_from_keys(owner, &[]);
676 crate::state::set_my_public_key(owner.public_key());
677
678 let mut community = crate::community::Community::create("HQ", "general", vec!["r".into()]);
679 let cid = community.id.to_hex();
680 community.owner_attestation = Some(
681 crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &cid)
682 .sign_with_keys(owner)
683 .unwrap()
684 .as_json(),
685 );
686 crate::db::community::save_community(&community).unwrap();
687
688 let role = Role::admin("a".repeat(64));
691 let roster = CommunityRoles {
692 grants: vec![MemberGrant { member: admin.to_hex(), role_ids: vec![role.role_id.clone()] }],
693 roles: vec![role],
694 };
695 crate::db::community::set_community_roles(&cid, &roster, 0).unwrap();
696 let entity_id = crate::community::derive::grant_locator(&community.id, &admin.to_bytes());
697 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
698 let hash = [0x5Au8; 32];
699 crate::db::community::set_edition_head(&cid, &entity_hex, 1, &hash).unwrap();
700
701 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
702 let channel = reloaded.channels[0].clone();
703 (tmp, guard, channel, AuthorityCitation { entity_id, version: 1, edition_hash: hash })
704 }
705
706 fn seal_hide(channel: &Channel, author: &Keys, target: &str, ms: u64, citation: Option<&AuthorityCitation>) -> Event {
708 let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
709 let inner = build_inner_full(
710 author.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_DELETE, "", ms, Some(target), &[], &extra,
711 )
712 .sign_with_keys(author)
713 .unwrap();
714 seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
715 }
716
717 fn ingest_msg_in(state: &mut ChatState, channel: &Channel, author: &Keys, content: &str, ms: u64, viewer: &Keys) -> String {
719 let outer = seal_message(author, &channel.key, &channel.id, channel.epoch, content, ms).unwrap();
720 match process_incoming(state, &outer, channel, &viewer.public_key()) {
721 Some(IncomingEvent::NewMessage(m)) => m.id,
722 _ => panic!("expected a new message"),
723 }
724 }
725
726 fn opened_from(author: &Keys, content: &str, ms: u64) -> OpenedMessage {
727 let key = ChannelKey([0x33u8; 32]);
728 let chan = ChannelId([0x44u8; 32]);
729 let outer = seal_message(author, &key, &chan, Epoch(0), content, ms).unwrap();
730 open_message(&outer, &key, &chan, Epoch(0)).unwrap()
731 }
732
733 fn test_channel() -> Channel {
734 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 }
735 }
736
737 fn seal_typed(author: &Keys, kind: u16, content: &str, ms: u64, target: &str) -> Event {
739 let c = test_channel();
740 let inner = build_inner_typed(author.public_key(), &c.id, c.epoch, kind, content, ms, Some(target), &[])
741 .sign_with_keys(author)
742 .unwrap();
743 seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
744 }
745
746 fn ingest_msg(state: &mut ChatState, author: &Keys, content: &str, ms: u64, viewer: &Keys) -> String {
747 let c = test_channel();
748 let outer = seal_message(author, &c.key, &c.id, c.epoch, content, ms).unwrap();
749 match process_incoming(state, &outer, &c, &viewer.public_key()) {
750 Some(IncomingEvent::NewMessage(m)) => m.id,
751 _ => panic!("expected a new message"),
752 }
753 }
754
755 #[test]
756 fn inbound_reaction_applies_to_target_and_dedups() {
757 use crate::stored_event::event_kind;
758 let mut state = ChatState::new();
759 let alice = Keys::generate();
760 let bob = Keys::generate();
761 let target = ingest_msg(&mut state, &alice, "hi", 1, &bob);
762
763 let react = seal_typed(&bob, event_kind::COMMUNITY_REACTION, "🔥", 2, &target);
764 match process_incoming(&mut state, &react, &test_channel(), &bob.public_key()) {
765 Some(IncomingEvent::Updated { target_id, message, edit_event: None }) => {
766 assert_eq!(target_id, target);
767 assert!(message.reactions.iter().any(|r| r.emoji == "🔥"), "reaction applied to target");
768 }
769 _ => panic!("expected a reaction update"),
770 }
771 assert!(process_incoming(&mut state, &react, &test_channel(), &bob.public_key()).is_none());
773 }
774
775 #[test]
776 fn bot_routing_tag_rides_the_v1_inner_into_addressed_bots() {
777 use nostr_sdk::prelude::ToBech32;
778 use crate::community::envelope::{build_inner_full, seal_with_signed_inner};
779 let mut state = ChatState::new();
780 let alice = Keys::generate();
781 let bot = Keys::generate();
782 let c = test_channel();
783
784 let inner = build_inner_full(
786 alice.public_key(),
787 &c.id,
788 c.epoch,
789 crate::stored_event::event_kind::COMMUNITY_MESSAGE,
790 "/roll 20",
791 5,
792 None,
793 &[],
794 &[crate::bot_interface::bot_tag(&bot.public_key())],
795 )
796 .sign_with_keys(&alice)
797 .unwrap();
798 let outer = seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap();
799
800 let opened = open_message(&outer, &c.key, &c.id, c.epoch).unwrap();
803 let msg = build_message(&opened, &alice.public_key());
804 assert_eq!(msg.addressed_bots, vec![bot.public_key().to_bech32().unwrap()]);
805
806 match process_incoming(&mut state, &outer, &c, &alice.public_key()) {
807 Some(IncomingEvent::NewMessage(m)) => {
808 assert_eq!(m.addressed_bots.len(), 1, "ingest keeps the routing tag");
809 }
810 _ => panic!("expected a new message"),
811 }
812 }
813
814 #[test]
815 fn reaction_cross_channel_is_rejected() {
816 use crate::stored_event::event_kind;
819 use crate::community::envelope::{build_inner_typed, seal_with_signed_inner};
820 let mut state = ChatState::new();
821 let alice = Keys::generate();
822 let bob = Keys::generate();
823 let chan_a = test_channel();
824 let chan_b = Channel {
825 id: ChannelId([0x55u8; 32]), key: ChannelKey([0x66u8; 32]), epoch: Epoch(0),
826 name: "b".into(), banned: Vec::new(), protected: Vec::new(),
827 roster: Default::default(), epoch_keys: Vec::new(), dissolved: false,
828 };
829 let target = ingest_msg_in(&mut state, &chan_a, &alice, "hi", 1, &bob);
831 let inner = build_inner_typed(
833 bob.public_key(), &chan_b.id, chan_b.epoch, event_kind::COMMUNITY_REACTION, "🔥", 2, Some(&target), &[],
834 ).sign_with_keys(&bob).unwrap();
835 let outer = seal_with_signed_inner(&Keys::generate(), &inner, &chan_b.key, &chan_b.id, chan_b.epoch).unwrap();
836 assert!(
838 process_incoming(&mut state, &outer, &chan_b, &bob.public_key()).is_none(),
839 "a reaction sealed under another channel must not apply to this channel's message"
840 );
841 let (_c, msg) = state.find_message(&target).unwrap();
842 assert!(msg.reactions.is_empty(), "cross-channel reaction must not be applied");
843 }
844
845 #[test]
846 fn inbound_message_carries_multi_attachments() {
847 use crate::stored_event::event_kind;
848 use crate::community::attachments::attachment_to_imeta;
849 use crate::community::envelope::build_inner_full;
850 use crate::types::Attachment;
851 let mut state = ChatState::new();
852 let alice = Keys::generate();
853 let bob = Keys::generate();
854 let c = test_channel();
855
856 let mk = |n: &str, ext: &str| Attachment {
857 id: "x".into(), key: "0".repeat(64), nonce: format!("{:0<24}", crate::simd::hex::bytes_to_hex_string(n.as_bytes())),
858 extension: ext.into(), name: n.into(), url: format!("https://b/{n}"),
859 path: String::new(), size: 9, img_meta: None, downloading: false, downloaded: false,
860 webxdc_topic: None, group_id: None, original_hash: Some("a".repeat(64)),
861 scheme_version: None, mls_filename: None,
862 };
863 let imetas = vec![attachment_to_imeta(&mk("a.png", "png")), attachment_to_imeta(&mk("b.txt", "txt"))];
864 let inner = build_inner_full(
865 alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_MESSAGE,
866 "caption", 5, None, &[], &imetas,
867 ).sign_with_keys(&alice).unwrap();
868 let outer = seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap();
869
870 match process_incoming(&mut state, &outer, &c, &bob.public_key()) {
871 Some(IncomingEvent::NewMessage(m)) => {
872 assert_eq!(m.content, "caption", "caption + attachments coexist in one event");
873 assert_eq!(m.attachments.len(), 2);
874 assert_eq!(m.attachments[0].name, "a.png");
875 assert_eq!(m.attachments[1].name, "b.txt");
876 assert!(m.attachments.iter().all(|a| a.group_id.is_none()));
877 }
878 _ => panic!("expected new message with attachments"),
879 }
880 }
881
882 #[test]
883 fn inbound_edit_only_honored_from_original_author() {
884 use crate::stored_event::event_kind;
885 let mut state = ChatState::new();
886 let alice = Keys::generate();
887 let target = ingest_msg(&mut state, &alice, "original", 1, &alice);
888
889 let edit = seal_typed(&alice, event_kind::COMMUNITY_EDIT, "edited!", 2, &target);
891 match process_incoming(&mut state, &edit, &test_channel(), &alice.public_key()) {
892 Some(IncomingEvent::Updated { message, edit_event, .. }) => {
893 assert_eq!(message.content, "edited!");
894 assert!(message.edited);
895 let ev = edit_event.expect("edit surfaces a MESSAGE_EDIT event to persist");
897 assert_eq!(ev.kind, event_kind::MESSAGE_EDIT);
898 assert_eq!(ev.reference_id.as_deref(), Some(target.as_str()));
899 assert_eq!(ev.content, "edited!");
900 }
901 _ => panic!("expected an edit update"),
902 }
903
904 let mallory = Keys::generate();
906 let hijack = seal_typed(&mallory, event_kind::COMMUNITY_EDIT, "hijacked", 3, &target);
907 assert!(process_incoming(&mut state, &hijack, &test_channel(), &alice.public_key()).is_none());
908 assert_eq!(state.find_message(&target).unwrap().1.content, "edited!");
909 }
910
911 #[test]
912 fn cooperative_delete_only_honored_from_original_author() {
913 use crate::stored_event::event_kind;
914 let mut state = ChatState::new();
915 let alice = Keys::generate();
916 let mallory = Keys::generate();
917 let target = ingest_msg(&mut state, &alice, "secret", 1, &alice);
918
919 let hijack = seal_typed(&mallory, event_kind::COMMUNITY_DELETE, "", 2, &target);
921 assert!(process_incoming(&mut state, &hijack, &test_channel(), &alice.public_key()).is_none());
922 assert!(state.find_message(&target).is_some(), "non-author delete must not remove");
923
924 let del = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 3, &target);
926 match process_incoming(&mut state, &del, &test_channel(), &alice.public_key()) {
927 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
928 _ => panic!("expected a removal"),
929 }
930 assert!(state.find_message(&target).is_none(), "message gone after author delete");
931
932 let replay = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 4, &target);
934 assert!(process_incoming(&mut state, &replay, &test_channel(), &alice.public_key()).is_none());
935 }
936
937 #[test]
938 fn dissolved_community_still_honors_an_own_message_delete() {
939 use crate::stored_event::event_kind;
940 let mut state = ChatState::new();
941 let alice = Keys::generate();
942 let target = ingest_msg(&mut state, &alice, "alice's own message", 1, &alice);
943 let mut ch = test_channel();
944 ch.dissolved = true;
945 let del = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 2, &target);
948 match process_incoming(&mut state, &del, &ch, &alice.public_key()) {
949 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
950 _ => panic!("a self-delete must be honored in a dissolved community"),
951 }
952 assert!(state.find_message(&target).is_none(), "own message scrubbed from the dead community");
953 }
954
955 #[test]
956 fn admin_moderation_hide_removes_any_message() {
957 let owner = Keys::generate();
958 let admin = Keys::generate(); let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
960 let alice = Keys::generate(); let mallory = Keys::generate(); let mut state = ChatState::new();
963 let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
964
965 let hijack = seal_hide(&c, &mallory, &target, 2, None);
967 assert!(process_incoming(&mut state, &hijack, &c, &alice.public_key()).is_none());
968 assert!(state.find_message(&target).is_some(), "unprivileged hide rejected");
969
970 let hide = seal_hide(&c, &admin, &target, 3, Some(&cite));
972 match process_incoming(&mut state, &hide, &c, &alice.public_key()) {
973 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
974 _ => panic!("expected admin moderation-hide to remove the message"),
975 }
976 assert!(state.find_message(&target).is_none(), "admin hide removed the message");
977 }
978
979 #[test]
980 fn admin_hide_without_a_citation_is_dropped() {
981 let owner = Keys::generate();
985 let admin = Keys::generate();
986 let (_tmp, _guard, c, _cite) = db_roster_channel(&owner, &admin.public_key());
987 let alice = Keys::generate();
988 let mut state = ChatState::new();
989 let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
990
991 let hide = seal_hide(&c, &admin, &target, 2, None); assert!(process_incoming(&mut state, &hide, &c, &alice.public_key()).is_none());
993 assert!(state.find_message(&target).is_some(), "an uncited admin hide is dropped");
994 }
995
996 #[test]
997 fn hide_citing_an_unsynced_grant_version_is_dropped() {
998 let owner = Keys::generate();
1002 let admin = Keys::generate();
1003 let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1004 let alice = Keys::generate();
1005 let mut state = ChatState::new();
1006 let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
1007
1008 let ahead = AuthorityCitation { version: 2, ..cite };
1010 let hide = seal_hide(&c, &admin, &target, 2, Some(&ahead));
1011 assert!(process_incoming(&mut state, &hide, &c, &alice.public_key()).is_none());
1012 assert!(state.find_message(&target).is_some(), "a hide citing an unsynced version is dropped");
1013 }
1014
1015 #[test]
1016 fn hide_with_a_forged_citation_hash_is_dropped() {
1017 let owner = Keys::generate();
1019 let admin = Keys::generate();
1020 let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1021 let alice = Keys::generate();
1022 let mut state = ChatState::new();
1023 let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
1024
1025 let forged = AuthorityCitation { edition_hash: [0xEE; 32], ..cite };
1026 let hide = seal_hide(&c, &admin, &target, 2, Some(&forged));
1027 assert!(process_incoming(&mut state, &hide, &c, &alice.public_key()).is_none());
1028 assert!(state.find_message(&target).is_some(), "a forged-hash citation is dropped");
1029 }
1030
1031 #[test]
1032 fn protected_owner_cannot_be_moderation_hidden_but_others_can() {
1033 let owner = Keys::generate(); let admin = Keys::generate(); let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1036 let mut state = ChatState::new();
1037
1038 let owners_msg = ingest_msg_in(&mut state, &c, &owner, "owner speaks", 1, &owner);
1040 let hide_owner = seal_hide(&c, &admin, &owners_msg, 2, Some(&cite));
1041 assert!(process_incoming(&mut state, &hide_owner, &c, &owner.public_key()).is_none());
1042 assert!(state.find_message(&owners_msg).is_some(), "owner's message is protected");
1043
1044 let member = Keys::generate();
1046 let members_msg = ingest_msg_in(&mut state, &c, &member, "member speaks", 3, &owner);
1047 let hide_member = seal_hide(&c, &admin, &members_msg, 4, Some(&cite));
1048 match process_incoming(&mut state, &hide_member, &c, &owner.public_key()) {
1049 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, members_msg),
1050 _ => panic!("a non-protected member's message should be hideable"),
1051 }
1052 }
1053
1054 #[test]
1055 fn admin_hide_of_absent_target_defers_until_resident() {
1056 let owner = Keys::generate();
1064 let admin = Keys::generate();
1065 let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1066 let mut state = ChatState::new();
1067 let absent_target = "f".repeat(64); let hide = seal_hide(&c, &admin, &absent_target, 1, Some(&cite));
1070 assert!(
1071 process_incoming(&mut state, &hide, &c, &Keys::generate().public_key()).is_none(),
1072 "a hide of an absent target defers (None) rather than falsely tombstoning + self-deduping",
1073 );
1074
1075 let mallory = Keys::generate();
1077 let hijack = seal_hide(&c, &mallory, &absent_target, 2, None);
1078 assert!(process_incoming(&mut state, &hijack, &c, &mallory.public_key()).is_none());
1079
1080 let uncited = seal_hide(&c, &admin, &absent_target, 3, None);
1083 assert!(
1084 process_incoming(&mut state, &uncited, &c, &Keys::generate().public_key()).is_none(),
1085 "an admin's uncited hide of an unknown target is dropped (pinned gates the author-unknown path)"
1086 );
1087 }
1088
1089 #[tokio::test]
1090 async fn out_of_window_hide_authorizes_against_db_author() {
1091 use crate::types::Message;
1092 use nostr_sdk::ToBech32;
1096 let owner = Keys::generate();
1097 let admin = Keys::generate(); let member = Keys::generate();
1099 let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1100
1101 let owner_msg = "a".repeat(64);
1103 let member_msg = "b".repeat(64);
1104 let mk = |id: &str, author: &Keys, at: u64| {
1105 let mut m = Message::default();
1106 m.id = id.to_string();
1107 m.npub = Some(author.public_key().to_bech32().unwrap());
1108 m.at = at;
1109 m
1110 };
1111 crate::db::events::save_message("chatoow", &mk(&owner_msg, &owner, 1)).await.unwrap();
1112 crate::db::events::save_message("chatoow", &mk(&member_msg, &member, 2)).await.unwrap();
1113
1114 let mut state = ChatState::new();
1115 let hide_owner = seal_hide(&c, &admin, &owner_msg, 3, Some(&cite));
1117 assert!(
1118 process_incoming(&mut state, &hide_owner, &c, &member.public_key()).is_none(),
1119 "owner's paged-out message must not be hideable by an admin"
1120 );
1121 let hide_member = seal_hide(&c, &admin, &member_msg, 4, Some(&cite));
1123 match process_incoming(&mut state, &hide_member, &c, &owner.public_key()) {
1124 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, member_msg),
1125 _ => panic!("admin should hide a member's paged-out message"),
1126 }
1127
1128 let member_msg2 = "c".repeat(64);
1132 crate::db::events::save_message("chatoow", &mk(&member_msg2, &member, 5)).await.unwrap();
1133 let mut sealed = c.clone();
1134 sealed.dissolved = true;
1135 let hide_sealed = seal_hide(&sealed, &admin, &member_msg2, 6, Some(&cite));
1136 assert!(
1137 process_incoming(&mut state, &hide_sealed, &sealed, &owner.public_key()).is_none(),
1138 "a dissolved community accepts no moderation-hide, resident or paged-out"
1139 );
1140 let self_del = seal_hide(&sealed, &member, &member_msg2, 7, None);
1141 match process_incoming(&mut state, &self_del, &sealed, &owner.public_key()) {
1142 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, member_msg2),
1143 _ => panic!("a self-delete of a paged-out message must survive the dissolved seal"),
1144 }
1145 crate::db::close_database();
1146 }
1147
1148 fn seal_kick(channel: &Channel, author: &Keys, target_hex: &str, ms: u64, citation: Option<&AuthorityCitation>) -> Event {
1150 let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
1151 let inner = build_inner_full(
1152 author.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_KICK, target_hex, ms, None, &[], &extra,
1153 )
1154 .sign_with_keys(author)
1155 .unwrap();
1156 seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
1157 }
1158
1159 fn post_join_ms() -> u64 {
1162 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
1163 (now + 5) * 1000
1164 }
1165
1166 #[test]
1167 fn cited_admin_kick_of_local_user_yields_self_removal() {
1168 let owner = Keys::generate();
1169 let admin = Keys::generate();
1170 let member = Keys::generate();
1171 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1172 let mut state = ChatState::new();
1173 let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), post_join_ms(), Some(&cite));
1175 match process_incoming(&mut state, &kick, &channel, &member.public_key()) {
1176 Some(IncomingEvent::Kicked { community_id }) => assert!(!community_id.is_empty()),
1177 _ => panic!("expected Kicked"),
1178 }
1179 crate::db::close_database();
1180 }
1181
1182 #[test]
1183 fn cited_admin_kick_of_other_member_is_a_leave() {
1184 use nostr_sdk::ToBech32;
1185 let owner = Keys::generate();
1186 let admin = Keys::generate();
1187 let member = Keys::generate();
1188 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1189 let mut state = ChatState::new();
1190 let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), post_join_ms(), Some(&cite));
1193 match process_incoming(&mut state, &kick, &channel, &owner.public_key()) {
1194 Some(IncomingEvent::Presence { npub, joined, .. }) => {
1195 assert!(!joined);
1196 assert_eq!(npub, member.public_key().to_bech32().unwrap());
1197 }
1198 _ => panic!("expected leave Presence"),
1199 }
1200 crate::db::close_database();
1201 }
1202
1203 #[test]
1204 fn uncited_kick_is_dropped() {
1205 let owner = Keys::generate();
1206 let admin = Keys::generate();
1207 let member = Keys::generate();
1208 let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1209 let mut state = ChatState::new();
1210 let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), 1, None);
1211 assert!(process_incoming(&mut state, &kick, &channel, &member.public_key()).is_none(),
1212 "a non-owner kick without a citation is dropped");
1213 crate::db::close_database();
1214 }
1215
1216 #[test]
1217 fn unprivileged_kick_is_dropped() {
1218 let owner = Keys::generate();
1219 let admin = Keys::generate();
1220 let mallory = Keys::generate();
1221 let member = Keys::generate();
1222 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1223 let mut state = ChatState::new();
1224 let kick = seal_kick(&channel, &mallory, &member.public_key().to_hex(), 1, Some(&cite));
1227 assert!(process_incoming(&mut state, &kick, &channel, &member.public_key()).is_none(),
1228 "a kick from an unranked actor is dropped");
1229 crate::db::close_database();
1230 }
1231
1232 #[test]
1233 fn kick_of_owner_is_dropped() {
1234 let owner = Keys::generate();
1235 let admin = Keys::generate();
1236 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1237 let mut state = ChatState::new();
1238 let kick = seal_kick(&channel, &admin, &owner.public_key().to_hex(), post_join_ms(), Some(&cite));
1240 assert!(process_incoming(&mut state, &kick, &channel, &owner.public_key()).is_none(),
1241 "an admin cannot kick the owner");
1242 crate::db::close_database();
1243 }
1244
1245 #[test]
1246 fn stale_kick_predating_join_is_dropped() {
1247 let owner = Keys::generate();
1248 let admin = Keys::generate();
1249 let member = Keys::generate();
1250 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1251 let mut state = ChatState::new();
1252 let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), 1, Some(&cite));
1255 assert!(process_incoming(&mut state, &kick, &channel, &member.public_key()).is_none(),
1256 "a kick older than the current join is dropped");
1257 crate::db::close_database();
1258 }
1259
1260 #[test]
1261 fn webxdc_signals_parse_ad_and_left_and_reject_garbage() {
1262 use crate::stored_event::event_kind;
1263 use nostr_sdk::ToBech32;
1264 let mut state = ChatState::new();
1265 let alice = Keys::generate();
1266 let c = test_channel();
1267 let viewer = Keys::generate();
1268 let mk = |content: &str, ms: u64| {
1269 let inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_WEBXDC, content, ms, None, &[])
1270 .sign_with_keys(&alice).unwrap();
1271 seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
1272 };
1273 let topic = crate::webxdc::mint_topic_id("game-hash", "sender");
1274
1275 let ad = serde_json::json!({ "op": "ad", "topic": topic, "addr": "BASE32NODEADDR" }).to_string();
1277 match process_incoming(&mut state, &mk(&ad, 1), &c, &viewer.public_key()) {
1278 Some(IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, .. }) => {
1279 assert_eq!(npub, alice.public_key().to_bech32().unwrap(), "player is the inner author");
1280 assert_eq!(topic_id, topic);
1281 assert_eq!(node_addr.as_deref(), Some("BASE32NODEADDR"));
1282 }
1283 _ => panic!("expected a webxdc advertisement"),
1284 }
1285
1286 let left = serde_json::json!({ "op": "left", "topic": topic }).to_string();
1288 match process_incoming(&mut state, &mk(&left, 2), &c, &viewer.public_key()) {
1289 Some(IncomingEvent::WebxdcPeer { node_addr, .. }) => {
1290 assert!(node_addr.is_none(), "peer-left carries no addr");
1291 }
1292 _ => panic!("expected a webxdc peer-left"),
1293 }
1294
1295 assert!(
1297 process_incoming(&mut state, &mk(&ad, 3), &c, &alice.public_key()).is_none(),
1298 "own webxdc signal must be ignored"
1299 );
1300
1301 let bad_topic = serde_json::json!({ "op": "ad", "topic": "../../etc", "addr": "X" }).to_string();
1303 assert!(process_incoming(&mut state, &mk(&bad_topic, 4), &c, &viewer.public_key()).is_none());
1304 let bad_op = serde_json::json!({ "op": "explode", "topic": topic }).to_string();
1305 assert!(process_incoming(&mut state, &mk(&bad_op, 5), &c, &viewer.public_key()).is_none());
1306 let no_addr = serde_json::json!({ "op": "ad", "topic": topic }).to_string();
1307 assert!(process_incoming(&mut state, &mk(&no_addr, 6), &c, &viewer.public_key()).is_none());
1308 assert!(process_incoming(&mut state, &mk("not json", 7), &c, &viewer.public_key()).is_none());
1309 }
1310
1311 #[test]
1312 fn typing_indicator_parses_drops_own_echo_and_rejects_garbage() {
1313 use crate::stored_event::event_kind;
1314 use nostr_sdk::ToBech32;
1315 let mut state = ChatState::new();
1316 let alice = Keys::generate();
1317 let c = test_channel();
1318 let viewer = Keys::generate();
1319 let mk = |content: &str, ms: u64| {
1320 let inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_TYPING, content, ms, None, &[])
1321 .sign_with_keys(&alice).unwrap();
1322 seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
1323 };
1324 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
1325
1326 match process_incoming(&mut state, &mk("typing", 1), &c, &viewer.public_key()) {
1329 Some(IncomingEvent::Typing { npub, until }) => {
1330 assert_eq!(npub, alice.public_key().to_bech32().unwrap(), "typer is the inner author");
1331 assert!(until >= now && until <= now + 31, "until is receiver-computed (~now + 30s)");
1332 }
1333 _ => panic!("expected a typing indicator"),
1334 }
1335
1336 assert!(
1338 process_incoming(&mut state, &mk("typing", 2), &c, &alice.public_key()).is_none(),
1339 "own typing signal must be ignored"
1340 );
1341
1342 assert!(process_incoming(&mut state, &mk("nope", 3), &c, &viewer.public_key()).is_none());
1344 }
1345
1346 #[test]
1347 fn presence_announcements_parse_join_and_leave() {
1348 use crate::stored_event::event_kind;
1349 let mut state = ChatState::new();
1350 let alice = Keys::generate();
1351 let c = test_channel();
1352 let viewer = Keys::generate();
1353 let mk = |content: &str, ms: u64| {
1354 let inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_PRESENCE, content, ms, None, &[])
1355 .sign_with_keys(&alice).unwrap();
1356 seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
1357 };
1358 match process_incoming(&mut state, &mk("join", 1), &c, &viewer.public_key()) {
1359 Some(IncomingEvent::Presence { npub, joined, .. }) => {
1360 assert!(joined, "content 'join' → joined");
1361 assert_eq!(npub, alice.public_key().to_bech32().unwrap(), "announcer is the inner author");
1362 }
1363 _ => panic!("expected a join presence"),
1364 }
1365 match process_incoming(&mut state, &mk("leave", 2), &c, &viewer.public_key()) {
1366 Some(IncomingEvent::Presence { joined, invited_by, .. }) => {
1367 assert!(!joined, "content 'leave' → not joined");
1368 assert!(invited_by.is_none(), "a plain leave carries no attribution");
1369 }
1370 _ => panic!("expected a leave presence"),
1371 }
1372 let jean = Keys::generate().public_key().to_bech32().unwrap();
1375 let attributed = serde_json::json!({ "by": jean, "l": "Reddit" }).to_string();
1376 match process_incoming(&mut state, &mk(&attributed, 3), &c, &viewer.public_key()) {
1377 Some(IncomingEvent::Presence { joined, invited_by, invited_label, .. }) => {
1378 assert!(joined, "an attributed-join JSON is still a join");
1379 assert_eq!(invited_by.as_deref(), Some(jean.as_str()), "valid inviter npub surfaced");
1380 assert_eq!(invited_label.as_deref(), Some("Reddit"), "link label surfaced");
1381 }
1382 _ => panic!("expected an attributed join presence"),
1383 }
1384 let forged = serde_json::json!({ "by": "haha not an npub", "l": "x" }).to_string();
1386 match process_incoming(&mut state, &mk(&forged, 4), &c, &viewer.public_key()) {
1387 Some(IncomingEvent::Presence { invited_by, .. }) => assert!(invited_by.is_none(), "forged inviter dropped"),
1388 _ => panic!("expected a join presence"),
1389 }
1390 }
1391
1392 #[test]
1393 fn leave_presence_authored_by_local_npub_yields_self_left() {
1394 use crate::stored_event::event_kind;
1395 let owner = Keys::generate();
1398 let admin = Keys::generate();
1399 let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1400 let mut state = ChatState::new();
1401 let cid = crate::db::community::community_id_for_channel(&channel.id.to_hex()).unwrap().unwrap();
1404 let cid_bytes = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid));
1405 let leave_ms = crate::db::community::community_created_at_ms(&cid_bytes).unwrap_or(0) + 10_000;
1406 let leave = {
1407 let inner = build_inner_typed(owner.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_PRESENCE, "leave", leave_ms, None, &[])
1408 .sign_with_keys(&owner).unwrap();
1409 seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
1410 };
1411 match process_incoming(&mut state, &leave, &channel, &owner.public_key()) {
1412 Some(IncomingEvent::SelfLeft { community_id }) => assert!(!community_id.is_empty()),
1413 _ => panic!("expected SelfLeft"),
1414 }
1415 crate::db::close_database();
1416 }
1417
1418 #[test]
1419 fn leave_presence_authored_by_another_npub_stays_a_plain_leave() {
1420 use crate::stored_event::event_kind;
1421 use nostr_sdk::ToBech32;
1422 let owner = Keys::generate();
1424 let admin = Keys::generate();
1425 let other = Keys::generate();
1426 let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1427 let mut state = ChatState::new();
1428 let leave = {
1429 let inner = build_inner_typed(other.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_PRESENCE, "leave", 2, None, &[])
1430 .sign_with_keys(&other).unwrap();
1431 seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
1432 };
1433 match process_incoming(&mut state, &leave, &channel, &owner.public_key()) {
1435 Some(IncomingEvent::Presence { npub, joined, .. }) => {
1436 assert!(!joined);
1437 assert_eq!(npub, other.public_key().to_bech32().unwrap());
1438 }
1439 _ => panic!("expected plain leave Presence"),
1440 }
1441 crate::db::close_database();
1442 }
1443
1444 #[test]
1445 fn self_delete_still_applies_after_keep_keys_teardown() {
1446 let owner = Keys::generate();
1449 let admin = Keys::generate();
1450 let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1451 let cid = crate::db::community::community_id_for_channel(&channel.id.to_hex()).unwrap().unwrap();
1452 let chan_hex = channel.id.to_hex();
1453 let epoch = channel.epoch.0;
1454
1455 let mut state = ChatState::new();
1457 let target = ingest_msg_in(&mut state, &channel, &owner, "mine", 1, &owner);
1458
1459 crate::db::community::delete_community_retain_keys(&cid).unwrap();
1461 let retained = crate::db::community::held_epoch_key(&cid, &chan_hex, epoch).unwrap()
1462 .expect("epoch key retained after keep-keys teardown");
1463 let mut rebuilt = channel.clone();
1464 rebuilt.key = ChannelKey(retained);
1465 rebuilt.epoch = Epoch(epoch);
1466
1467 let del = seal_hide(&rebuilt, &owner, &target, 2, None);
1469 match process_incoming(&mut state, &del, &rebuilt, &owner.public_key()) {
1470 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
1471 _ => panic!("expected the self-delete to apply under the retained key"),
1472 }
1473 crate::db::close_database();
1474 }
1475
1476 #[test]
1477 fn banned_author_events_are_dropped_including_presence() {
1478 use crate::stored_event::event_kind;
1479 let mut state = ChatState::new();
1480 let alice = Keys::generate(); let bob = Keys::generate();
1482 let mut c = test_channel();
1483 c.banned = vec![alice.public_key()];
1484
1485 let spam = seal_message(&alice, &c.key, &c.id, c.epoch, "spam", 1).unwrap();
1487 assert!(process_incoming(&mut state, &spam, &c, &bob.public_key()).is_none(), "banned message dropped");
1488
1489 let pres_inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_PRESENCE, "join", 2, None, &[])
1491 .sign_with_keys(&alice).unwrap();
1492 let pres = seal_with_signed_inner(&Keys::generate(), &pres_inner, &c.key, &c.id, c.epoch).unwrap();
1493 assert!(process_incoming(&mut state, &pres, &c, &bob.public_key()).is_none(), "banned presence dropped");
1494
1495 let ok = seal_message(&bob, &c.key, &c.id, c.epoch, "hi", 3).unwrap();
1497 assert!(matches!(process_incoming(&mut state, &ok, &c, &bob.public_key()), Some(IncomingEvent::NewMessage(_))), "non-banned applied");
1498 }
1499
1500 #[test]
1501 fn cooperative_delete_applies_after_message_in_batch_order() {
1502 use crate::stored_event::event_kind;
1503 let mut state = ChatState::new();
1504 let alice = Keys::generate();
1505 let c = test_channel();
1506
1507 let msg_outer = seal_message(&alice, &c.key, &c.id, c.epoch, "bye", 1).unwrap();
1511 let opened = open_message(&msg_outer, &c.key, &c.id, c.epoch).unwrap();
1512 let inner_id = opened.message_id.to_hex();
1513 let del_outer = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 2, &inner_id);
1514
1515 let applied = process_channel_batch(&mut state, &[del_outer, msg_outer], &c, &alice.public_key());
1516 assert!(applied.iter().any(|e| matches!(e, IncomingEvent::NewMessage(_))));
1517 assert!(applied.iter().any(|e| matches!(e, IncomingEvent::Removed { .. })));
1518 assert!(state.find_message(&inner_id).is_none(), "delete applied despite arriving first");
1519 }
1520
1521 #[test]
1522 fn build_message_sets_mine_and_author() {
1523 let me = Keys::generate();
1524 let opened = opened_from(&me, "hello", 4242);
1525 let msg = build_message(&opened, &me.public_key());
1526 assert_eq!(msg.content, "hello");
1527 assert_eq!(msg.at, 4242);
1528 assert!(msg.mine, "author == me → mine");
1529 assert_eq!(msg.npub, me.public_key().to_bech32().ok());
1530 assert_eq!(msg.id, opened.message_id.to_hex());
1531
1532 let other_view = build_message(&opened, &Keys::generate().public_key());
1534 assert!(!other_view.mine);
1535 }
1536
1537 #[test]
1538 fn ingest_creates_community_chat_and_adds_message() {
1539 let mut state = ChatState::new();
1540 let alice = Keys::generate();
1541 let opened = opened_from(&alice, "gm", 1);
1542
1543 assert!(ingest_message(&mut state, &opened, &alice.public_key()).is_some());
1544 let chat = state.chats.iter().find(|c| c.id == opened.channel_id.to_hex()).expect("chat");
1546 assert!(chat.is_community(), "channel chat must be ChatType::Community");
1547 }
1548
1549 #[test]
1550 fn process_incoming_ingests_valid_drops_foreign() {
1551 let mut state = ChatState::new();
1552 let alice = Keys::generate();
1553 let key = ChannelKey([0x33u8; 32]);
1554 let chan = ChannelId([0x44u8; 32]);
1555 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 };
1556
1557 let outer = seal_message(&alice, &key, &chan, Epoch(0), "real", 1).unwrap();
1559 assert!(process_incoming(&mut state, &outer, &channel, &alice.public_key()).is_some());
1560 assert!(state.chats.iter().any(|c| c.is_community()));
1561
1562 let other_key = ChannelKey([0x99u8; 32]);
1564 let other_chan = ChannelId([0xaau8; 32]);
1565 let foreign = seal_message(&alice, &other_key, &other_chan, Epoch(0), "nope", 1).unwrap();
1566 assert!(process_incoming(&mut state, &foreign, &channel, &alice.public_key()).is_none());
1567 assert_eq!(state.chats.iter().filter(|c| c.is_community()).count(), 1);
1568 }
1569
1570 #[test]
1571 fn ingest_dedups_on_message_id() {
1572 let mut state = ChatState::new();
1573 let alice = Keys::generate();
1574 let opened = opened_from(&alice, "once", 1);
1575
1576 assert!(ingest_message(&mut state, &opened, &alice.public_key()).is_some(), "first add");
1577 assert!(ingest_message(&mut state, &opened, &alice.public_key()).is_none(), "duplicate not re-added");
1578 assert_eq!(state.chats.iter().filter(|c| c.is_community()).count(), 1);
1580 }
1581
1582 #[test]
1583 fn dedup_keys_on_inner_id_across_distinct_outer_events() {
1584 let mut state = ChatState::new();
1590 let alice = Keys::generate();
1591 let key = ChannelKey([0x33u8; 32]);
1592 let chan = ChannelId([0x44u8; 32]);
1593 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 };
1594
1595 let outer_a = seal_message(&alice, &key, &chan, Epoch(0), "dup", 7).unwrap();
1596 let outer_b = seal_message(&alice, &key, &chan, Epoch(0), "dup", 7).unwrap();
1597 assert_ne!(outer_a.id, outer_b.id, "distinct outer events (fresh ephemeral + nonce)");
1598
1599 assert!(process_incoming(&mut state, &outer_a, &channel, &alice.public_key()).is_some());
1600 assert!(
1601 process_incoming(&mut state, &outer_b, &channel, &alice.public_key()).is_none(),
1602 "same inner message id must dedup despite a different outer event"
1603 );
1604 }
1605
1606 #[test]
1607 fn route_incoming_routes_by_pseudonym() {
1608 let mut state = ChatState::new();
1609 let alice = Keys::generate();
1610 let key = ChannelKey([0x33u8; 32]);
1611 let chan = ChannelId([0x44u8; 32]);
1612 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 };
1613
1614 let mut routes = HashMap::new();
1616 routes.insert(channel_pseudonym(&key, &chan, Epoch(0)).to_hex(), channel.clone());
1617
1618 let outer = seal_message(&alice, &key, &chan, Epoch(0), "routed", 1).unwrap();
1620 assert!(route_incoming(&mut state, &outer, &routes, &alice.public_key()).is_some());
1621
1622 let other_key = ChannelKey([0x55u8; 32]);
1624 let other_chan = ChannelId([0x66u8; 32]);
1625 let unrouted = seal_message(&alice, &other_key, &other_chan, Epoch(0), "x", 1).unwrap();
1626 assert!(route_incoming(&mut state, &unrouted, &routes, &alice.public_key()).is_none());
1627 }
1628
1629 #[test]
1630 fn ms_none_falls_back_to_created_at() {
1631 use nostr_sdk::prelude::{EventId, Timestamp, Tags};
1633 let author = Keys::generate();
1634 let opened = OpenedMessage {
1635 message_id: EventId::all_zeros(),
1636 author: author.public_key(),
1637 content: "no ms".into(),
1638 channel_id: ChannelId([1u8; 32]),
1639 epoch: Epoch(0),
1640 ms: None,
1641 created_at: Timestamp::from_secs(1500),
1642 kind: 3300,
1643 attachments: vec![],
1644 citation: None,
1645 wrapper_id: EventId::all_zeros(),
1646 tags: Tags::new(),
1647 };
1648 assert_eq!(build_message(&opened, &author.public_key()).at, 1_500_000);
1649 }
1650}