use nostr_sdk::prelude::{Event, PublicKey};
use nostr_sdk::ToBech32;
use super::envelope::{open_message_multi, OpenedMessage};
use super::Channel;
use crate::state::ChatState;
use crate::stored_event::event_kind;
use crate::types::Message;
fn concord_rumor(
opened: &OpenedMessage,
kind: nostr_sdk::Kind,
my_pubkey: &PublicKey,
) -> (crate::rumor::RumorEvent, crate::rumor::RumorContext) {
use crate::rumor::{ConversationType, RumorContext, RumorEvent};
(
RumorEvent {
id: opened.message_id,
kind,
content: opened.content.clone(),
tags: opened.tags.clone(),
created_at: opened.created_at,
pubkey: opened.author,
},
RumorContext {
sender: opened.author,
is_mine: opened.author == *my_pubkey,
conversation_id: opened.channel_id.to_hex(),
conversation_type: ConversationType::Community,
},
)
}
pub fn build_message(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Message {
use crate::rumor::{process_rumor, RumorProcessingResult};
let (rumor, ctx) = concord_rumor(opened, nostr_sdk::Kind::PrivateDirectMessage, my_pubkey);
let mut msg = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
Ok(RumorProcessingResult::TextMessage(m)) => m,
_ => Message {
id: opened.message_id.to_hex(),
content: opened.content.clone(),
at: opened.ms.unwrap_or_else(|| opened.created_at.as_secs().saturating_mul(1000)),
mine: opened.author == *my_pubkey,
npub: opened.author.to_bech32().ok(),
..Default::default()
},
};
msg.attachments = opened.attachments.clone();
msg.wrapper_event_id = Some(opened.wrapper_id.to_hex());
msg
}
pub fn ingest_message(
state: &mut ChatState,
opened: &OpenedMessage,
my_pubkey: &PublicKey,
) -> Option<Message> {
let chat_id = opened.channel_id.to_hex();
let msg = build_message(opened, my_pubkey);
if crate::db::events::event_exists(&msg.id).unwrap_or(false) {
return None;
}
state.ensure_community_chat(&chat_id);
if state.add_message_to_chat(&chat_id, msg.clone()) {
Some(msg)
} else {
None
}
}
pub enum IncomingEvent {
NewMessage(Message),
Updated { target_id: String, message: Message, edit_event: Option<Box<crate::stored_event::StoredEvent>> },
Removed { target_id: String },
ReactionRemoved { message_id: String, reaction_id: String, message: Message },
Presence { npub: String, joined: bool, event_id: String, created_at: u64, invited_by: Option<String>, invited_label: Option<String> },
Kicked { community_id: String },
SelfLeft { community_id: String },
WebxdcPeer {
npub: String,
topic_id: String,
node_addr: Option<String>,
event_id: String,
created_at: u64,
},
Typing { npub: String, until: u64 },
}
pub fn process_incoming(
state: &mut ChatState,
event: &Event,
channel: &Channel,
my_pubkey: &PublicKey,
) -> Option<IncomingEvent> {
let outer_bytes = event.id.to_bytes();
if crate::db::events::wrapper_event_exists(&event.id.to_hex()).unwrap_or(false)
|| crate::db::wrappers::processed_wrapper_exists(&outer_bytes)
{
return None;
}
if channel.dissolved && event.kind.as_u16() != event_kind::COMMUNITY_DELETE {
return None;
}
let opened = match open_message_multi(event, &channel.id, &channel.read_epoch_keys()) {
Ok(o) => o,
Err(e) => {
crate::log_debug!("[community] inbound drop {}: {}", event.id.to_hex(), e);
return None;
}
};
if channel.banned.contains(&opened.author) {
crate::log_debug!("[community] dropped event from banned author {}", opened.author.to_hex());
return None;
}
let outcome = match opened.kind {
k if k == event_kind::COMMUNITY_MESSAGE => {
ingest_message(state, &opened, my_pubkey).map(IncomingEvent::NewMessage)
}
k if k == event_kind::COMMUNITY_REACTION => apply_reaction(state, &opened, my_pubkey),
k if k == event_kind::COMMUNITY_EDIT => apply_edit(state, &opened, my_pubkey),
k if k == event_kind::COMMUNITY_DELETE => apply_delete(state, &opened, channel, my_pubkey),
k if k == event_kind::COMMUNITY_PRESENCE => apply_presence(&opened, channel, my_pubkey),
k if k == event_kind::COMMUNITY_KICK => apply_kick(&opened, channel, my_pubkey),
k if k == event_kind::COMMUNITY_WEBXDC => apply_webxdc(&opened, my_pubkey),
k if k == event_kind::COMMUNITY_TYPING => apply_typing(&opened, my_pubkey),
_ => None,
};
if let Some(ref evt) = outcome {
if !matches!(evt, IncomingEvent::NewMessage(_) | IncomingEvent::Typing { .. }) {
let _ = crate::db::wrappers::save_processed_wrapper(
&outer_bytes, event.created_at.as_secs(), crate::db::wrappers::TRANSPORT_CONCORD,
);
}
}
outcome
}
fn apply_presence(opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
let joined = opened.content != "leave";
if !joined && opened.author == *my_pubkey {
if let Ok(Some(cid)) = crate::db::community::community_id_for_channel(&channel.id.to_hex()) {
let cid_bytes = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid));
let join_ms = crate::db::community::community_created_at_ms(&cid_bytes).unwrap_or(0);
if opened.created_at.as_secs().saturating_mul(1000) > join_ms {
return Some(IncomingEvent::SelfLeft { community_id: cid });
}
crate::log_debug!("[community] self-leave predates this join — rendering as history, not teardown");
}
}
let (invited_by, invited_label) = if joined {
serde_json::from_str::<serde_json::Value>(&opened.content)
.ok()
.map(|v| {
let by = v.get("by").and_then(|b| b.as_str())
.filter(|s| PublicKey::parse(s).is_ok())
.map(str::to_string);
let label = v.get("l").and_then(|l| l.as_str())
.map(|s| s.chars().take(48).collect::<String>())
.filter(|s| !s.is_empty());
(by, label)
})
.unwrap_or((None, None))
} else {
(None, None)
};
Some(IncomingEvent::Presence {
npub: opened.author.to_bech32().ok()?,
joined,
event_id: opened.message_id.to_hex(),
created_at: clamp_inner_secs(opened.created_at.as_secs()),
invited_by,
invited_label,
})
}
fn clamp_inner_secs(secs: u64) -> u64 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
secs.min(now + 300)
}
fn apply_webxdc(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
if opened.author == *my_pubkey {
return None;
}
let v: serde_json::Value = serde_json::from_str(&opened.content).ok()?;
let topic_id = v.get("topic").and_then(|t| t.as_str())
.filter(|t| t.len() == 52 && t.bytes().all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b)))?
.to_string();
let node_addr = match v.get("op").and_then(|o| o.as_str())? {
"ad" => Some(
v.get("addr").and_then(|a| a.as_str())
.filter(|a| !a.is_empty() && a.len() <= 2048)?
.to_string(),
),
"left" => None,
_ => return None,
};
Some(IncomingEvent::WebxdcPeer {
npub: opened.author.to_bech32().ok()?,
topic_id,
node_addr,
event_id: opened.message_id.to_hex(),
created_at: opened.created_at.as_secs(),
})
}
fn apply_typing(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
if opened.author == *my_pubkey {
return None;
}
if opened.content != "typing" {
return None;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
Some(IncomingEvent::Typing {
npub: opened.author.to_bech32().ok()?,
until: now + 30,
})
}
fn apply_kick(opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
use crate::community::roles::Permissions;
let target = PublicKey::parse(opened.content.trim()).ok()?;
let target_hex = target.to_hex();
let kicker_hex = opened.author.to_hex();
let owner_hex = channel.protected.first().map(|pk| pk.to_hex());
let pinned = actor_authority_pinned(channel, owner_hex.as_deref(), &kicker_hex, opened.citation.as_ref());
if !(pinned && channel.roster.can_act_on_member(&kicker_hex, owner_hex.as_deref(), &target_hex, Permissions::KICK)) {
crate::log_debug!("[community] dropped kick: {kicker_hex} not authorized to kick {target_hex}");
return None;
}
let cid_hex = crate::db::community::community_id_for_channel(&channel.id.to_hex()).ok().flatten()?;
let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
let join_ms = crate::db::community::community_created_at_ms(&cid).unwrap_or(0);
if opened.created_at.as_secs().saturating_mul(1000) <= join_ms {
crate::log_debug!("[community] dropped stale kick of {target_hex} (predates this join)");
return None;
}
if target == *my_pubkey {
return Some(IncomingEvent::Kicked { community_id: cid_hex });
}
Some(IncomingEvent::Presence {
npub: target.to_bech32().ok()?,
joined: false,
event_id: opened.message_id.to_hex(),
created_at: clamp_inner_secs(opened.created_at.as_secs()),
invited_by: None,
invited_label: None,
})
}
pub fn event_authenticates(event: &Event, channel: &Channel) -> bool {
if crate::db::events::wrapper_event_exists(&event.id.to_hex()).unwrap_or(false)
|| crate::db::wrappers::processed_wrapper_exists(&event.id.to_bytes())
{
return true;
}
open_message_multi(event, &channel.id, &channel.read_epoch_keys()).is_ok()
}
pub fn process_channel_batch(
state: &mut ChatState,
events: &[Event],
channel: &Channel,
my_pubkey: &PublicKey,
) -> Vec<IncomingEvent> {
let mut out = Vec::new();
for want_message in [true, false] {
for ev in events {
let is_message = ev.kind.as_u16() == event_kind::COMMUNITY_MESSAGE;
if is_message != want_message {
continue;
}
if let Some(evt) = process_incoming(state, ev, channel, my_pubkey) {
out.push(evt);
}
}
}
out
}
fn apply_reaction(state: &mut ChatState, opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
use crate::rumor::{process_rumor, RumorProcessingResult};
let (rumor, ctx) = concord_rumor(opened, nostr_sdk::Kind::Reaction, my_pubkey);
let reaction = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
Ok(RumorProcessingResult::Reaction(r)) => r,
_ => return None,
};
let target_id = reaction.reference_id.clone();
let expected_chat = opened.channel_id.to_hex();
if !matches!(state.find_message(&target_id), Some((chat, _)) if chat.id == expected_chat) {
return None;
}
let (_chat_id, was_added) = state.add_reaction_to_message(&target_id, reaction)?;
if !was_added {
return None;
}
let (_chat, message) = state.find_message(&target_id)?;
Some(IncomingEvent::Updated { target_id, message, edit_event: None })
}
fn apply_edit(state: &mut ChatState, opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
use crate::rumor::{process_rumor, RumorProcessingResult};
let (rumor, ctx) = concord_rumor(opened, nostr_sdk::Kind::from(event_kind::MESSAGE_EDIT), my_pubkey);
let (target_id, new_content, edited_at, emoji_tags, edit_event) = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
Ok(RumorProcessingResult::Edit { message_id, new_content, edited_at, emoji_tags, event }) => (message_id, new_content, edited_at, emoji_tags, event),
_ => return None,
};
let editor_npub = opened.author.to_bech32().ok()?;
let target_author = state.find_message(&target_id).and_then(|(_, m)| m.npub)?;
if target_author != editor_npub {
crate::log_debug!("[community] dropped edit from non-author of {}", target_id);
return None;
}
let (_chat_id, message) = state.update_message(&target_id, |m| {
m.apply_edit(new_content.clone(), edited_at, emoji_tags.clone());
})?;
Some(IncomingEvent::Updated { target_id, message, edit_event: Some(Box::new(edit_event)) })
}
fn actor_authority_pinned(
channel: &Channel,
owner_hex: Option<&str>,
actor_hex: &str,
citation: Option<&super::edition::AuthorityCitation>,
) -> bool {
if owner_hex == Some(actor_hex) {
return true; }
if citation.is_none() {
return false; }
let Ok(Some(cid)) = crate::db::community::community_id_for_channel(&channel.id.to_hex()) else {
return false; };
let cid_bytes = crate::simd::hex::hex_to_bytes_32(&cid);
let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
&crate::community::CommunityId(cid_bytes),
&actor_bytes,
));
let head: Vec<super::roster::EntityHead> = crate::db::community::get_edition_head(&cid, &grant_hex)
.ok()
.flatten()
.map(|(version, self_hash)| super::roster::EntityHead { entity_hex: grant_hex.clone(), version, self_hash, inner_id: [0u8; 32], citation: None })
.into_iter()
.collect();
super::roster::authority_citation_satisfied(&head, owner_hex, actor_hex, &grant_hex, citation)
}
fn apply_delete(state: &mut ChatState, opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
use crate::community::roles::Permissions;
use crate::rumor::{process_rumor, RumorProcessingResult};
let (rumor, ctx) = concord_rumor(opened, nostr_sdk::Kind::EventDeletion, my_pubkey);
let target_id = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
Ok(RumorProcessingResult::DeletionRequest { target_event_id }) => target_event_id,
_ => return None,
};
let deleter = opened.author;
let deleter_hex = deleter.to_hex();
if let Some((_chat_id, message_id, author_npub, _is_comm)) = state.find_reaction(&target_id) {
let reactor_ok = PublicKey::parse(&author_npub).map(|pk| pk == deleter).unwrap_or(false);
if !reactor_ok {
crate::log_debug!("[community] dropped reaction-revoke: {deleter_hex} is not the reactor of {target_id}");
return None;
}
return state
.remove_reaction_from_message(&message_id, &target_id)
.map(|(_cid, message)| IncomingEvent::ReactionRemoved {
message_id,
reaction_id: target_id,
message,
});
}
let owner_hex = channel.protected.first().map(|pk| pk.to_hex());
let pinned = actor_authority_pinned(channel, owner_hex.as_deref(), &deleter_hex, opened.citation.as_ref());
let target_author = state
.find_message(&target_id)
.and_then(|(_, m)| m.npub.clone())
.and_then(|n| PublicKey::parse(&n).ok());
if let Some(author) = target_author {
if author == deleter {
return state.remove_message(&target_id).map(|_| IncomingEvent::Removed { target_id });
}
if pinned && !channel.dissolved && channel.roster.can_act_on_member(&deleter_hex, owner_hex.as_deref(), &author.to_hex(), Permissions::MANAGE_MESSAGES) {
return state.remove_message(&target_id).map(|_| IncomingEvent::Removed { target_id });
}
crate::log_debug!("[community] dropped delete: {deleter_hex} not authorized to remove {target_id}");
return None;
}
if let Ok(Some(author_npub)) = crate::db::events::event_author(&target_id) {
if let Ok(author) = PublicKey::parse(&author_npub) {
let ok = author == deleter
|| (pinned
&& !channel.dissolved
&& channel.roster.can_act_on_member(&deleter_hex, owner_hex.as_deref(), &author.to_hex(), Permissions::MANAGE_MESSAGES));
if ok {
return Some(IncomingEvent::Removed { target_id });
}
crate::log_debug!("[community] dropped out-of-window delete: {deleter_hex} not authorized over {target_id}");
return None;
}
}
None
}
pub fn route_incoming(
state: &mut ChatState,
event: &Event,
routes: &std::collections::HashMap<String, Channel>,
my_pubkey: &PublicKey,
) -> Option<IncomingEvent> {
let pseudonym = event.tags.iter().find_map(|t| {
let s = t.as_slice();
(s.len() >= 2 && s[0] == "z").then(|| s[1].clone())
})?;
let channel = routes.get(&pseudonym)?;
process_incoming(state, event, channel, my_pubkey)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::community::derive::channel_pseudonym;
use std::collections::HashMap;
use crate::community::envelope::{build_inner_full, build_inner_typed, open_message, seal_message, seal_with_signed_inner};
use crate::community::edition::AuthorityCitation;
use crate::community::{Channel, ChannelId, ChannelKey, Epoch};
use crate::state::ChatState;
use nostr_sdk::prelude::{Keys, Tag};
fn db_roster_channel(
owner: &Keys,
admin: &PublicKey,
) -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Channel, AuthorityCitation) {
use crate::community::roles::{CommunityRoles, MemberGrant, Role};
use nostr_sdk::{JsonUtil, ToBech32};
let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
crate::db::close_database();
let tmp = tempfile::tempdir().unwrap();
let account = owner.public_key().to_bech32().unwrap();
std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
crate::db::set_app_data_dir(tmp.path().to_path_buf());
crate::db::set_current_account(account.clone()).unwrap();
crate::db::init_database(&account).unwrap();
crate::state::MY_SECRET_KEY.store_from_keys(owner, &[]);
crate::state::set_my_public_key(owner.public_key());
let mut community = crate::community::Community::create("HQ", "general", vec!["r".into()]);
let cid = community.id.to_hex();
community.owner_attestation = Some(
crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &cid)
.sign_with_keys(owner)
.unwrap()
.as_json(),
);
crate::db::community::save_community(&community).unwrap();
let role = Role::admin("a".repeat(64));
let roster = CommunityRoles {
grants: vec![MemberGrant { member: admin.to_hex(), role_ids: vec![role.role_id.clone()] }],
roles: vec![role],
};
crate::db::community::set_community_roles(&cid, &roster, 0).unwrap();
let entity_id = crate::community::derive::grant_locator(&community.id, &admin.to_bytes());
let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
let hash = [0x5Au8; 32];
crate::db::community::set_edition_head(&cid, &entity_hex, 1, &hash).unwrap();
let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
let channel = reloaded.channels[0].clone();
(tmp, guard, channel, AuthorityCitation { entity_id, version: 1, edition_hash: hash })
}
fn seal_hide(channel: &Channel, author: &Keys, target: &str, ms: u64, citation: Option<&AuthorityCitation>) -> Event {
let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
let inner = build_inner_full(
author.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_DELETE, "", ms, Some(target), &[], &extra,
)
.sign_with_keys(author)
.unwrap();
seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
}
fn ingest_msg_in(state: &mut ChatState, channel: &Channel, author: &Keys, content: &str, ms: u64, viewer: &Keys) -> String {
let outer = seal_message(author, &channel.key, &channel.id, channel.epoch, content, ms).unwrap();
match process_incoming(state, &outer, channel, &viewer.public_key()) {
Some(IncomingEvent::NewMessage(m)) => m.id,
_ => panic!("expected a new message"),
}
}
fn opened_from(author: &Keys, content: &str, ms: u64) -> OpenedMessage {
let key = ChannelKey([0x33u8; 32]);
let chan = ChannelId([0x44u8; 32]);
let outer = seal_message(author, &key, &chan, Epoch(0), content, ms).unwrap();
open_message(&outer, &key, &chan, Epoch(0)).unwrap()
}
fn test_channel() -> Channel {
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 }
}
fn seal_typed(author: &Keys, kind: u16, content: &str, ms: u64, target: &str) -> Event {
let c = test_channel();
let inner = build_inner_typed(author.public_key(), &c.id, c.epoch, kind, content, ms, Some(target), &[])
.sign_with_keys(author)
.unwrap();
seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
}
fn ingest_msg(state: &mut ChatState, author: &Keys, content: &str, ms: u64, viewer: &Keys) -> String {
let c = test_channel();
let outer = seal_message(author, &c.key, &c.id, c.epoch, content, ms).unwrap();
match process_incoming(state, &outer, &c, &viewer.public_key()) {
Some(IncomingEvent::NewMessage(m)) => m.id,
_ => panic!("expected a new message"),
}
}
#[test]
fn inbound_reaction_applies_to_target_and_dedups() {
use crate::stored_event::event_kind;
let mut state = ChatState::new();
let alice = Keys::generate();
let bob = Keys::generate();
let target = ingest_msg(&mut state, &alice, "hi", 1, &bob);
let react = seal_typed(&bob, event_kind::COMMUNITY_REACTION, "🔥", 2, &target);
match process_incoming(&mut state, &react, &test_channel(), &bob.public_key()) {
Some(IncomingEvent::Updated { target_id, message, edit_event: None }) => {
assert_eq!(target_id, target);
assert!(message.reactions.iter().any(|r| r.emoji == "🔥"), "reaction applied to target");
}
_ => panic!("expected a reaction update"),
}
assert!(process_incoming(&mut state, &react, &test_channel(), &bob.public_key()).is_none());
}
#[test]
fn reaction_cross_channel_is_rejected() {
use crate::stored_event::event_kind;
use crate::community::envelope::{build_inner_typed, seal_with_signed_inner};
let mut state = ChatState::new();
let alice = Keys::generate();
let bob = Keys::generate();
let chan_a = test_channel();
let chan_b = Channel {
id: ChannelId([0x55u8; 32]), key: ChannelKey([0x66u8; 32]), epoch: Epoch(0),
name: "b".into(), banned: Vec::new(), protected: Vec::new(),
roster: Default::default(), epoch_keys: Vec::new(), dissolved: false,
};
let target = ingest_msg_in(&mut state, &chan_a, &alice, "hi", 1, &bob);
let inner = build_inner_typed(
bob.public_key(), &chan_b.id, chan_b.epoch, event_kind::COMMUNITY_REACTION, "🔥", 2, Some(&target), &[],
).sign_with_keys(&bob).unwrap();
let outer = seal_with_signed_inner(&Keys::generate(), &inner, &chan_b.key, &chan_b.id, chan_b.epoch).unwrap();
assert!(
process_incoming(&mut state, &outer, &chan_b, &bob.public_key()).is_none(),
"a reaction sealed under another channel must not apply to this channel's message"
);
let (_c, msg) = state.find_message(&target).unwrap();
assert!(msg.reactions.is_empty(), "cross-channel reaction must not be applied");
}
#[test]
fn inbound_message_carries_multi_attachments() {
use crate::stored_event::event_kind;
use crate::community::attachments::attachment_to_imeta;
use crate::community::envelope::build_inner_full;
use crate::types::Attachment;
let mut state = ChatState::new();
let alice = Keys::generate();
let bob = Keys::generate();
let c = test_channel();
let mk = |n: &str, ext: &str| Attachment {
id: "x".into(), key: "0".repeat(64), nonce: format!("{:0<24}", crate::simd::hex::bytes_to_hex_string(n.as_bytes())),
extension: ext.into(), name: n.into(), url: format!("https://b/{n}"),
path: String::new(), size: 9, img_meta: None, downloading: false, downloaded: false,
webxdc_topic: None, group_id: None, original_hash: Some("a".repeat(64)),
scheme_version: None, mls_filename: None,
};
let imetas = vec![attachment_to_imeta(&mk("a.png", "png")), attachment_to_imeta(&mk("b.txt", "txt"))];
let inner = build_inner_full(
alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_MESSAGE,
"caption", 5, None, &[], &imetas,
).sign_with_keys(&alice).unwrap();
let outer = seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap();
match process_incoming(&mut state, &outer, &c, &bob.public_key()) {
Some(IncomingEvent::NewMessage(m)) => {
assert_eq!(m.content, "caption", "caption + attachments coexist in one event");
assert_eq!(m.attachments.len(), 2);
assert_eq!(m.attachments[0].name, "a.png");
assert_eq!(m.attachments[1].name, "b.txt");
assert!(m.attachments.iter().all(|a| a.group_id.is_none()));
}
_ => panic!("expected new message with attachments"),
}
}
#[test]
fn inbound_edit_only_honored_from_original_author() {
use crate::stored_event::event_kind;
let mut state = ChatState::new();
let alice = Keys::generate();
let target = ingest_msg(&mut state, &alice, "original", 1, &alice);
let edit = seal_typed(&alice, event_kind::COMMUNITY_EDIT, "edited!", 2, &target);
match process_incoming(&mut state, &edit, &test_channel(), &alice.public_key()) {
Some(IncomingEvent::Updated { message, edit_event, .. }) => {
assert_eq!(message.content, "edited!");
assert!(message.edited);
let ev = edit_event.expect("edit surfaces a MESSAGE_EDIT event to persist");
assert_eq!(ev.kind, event_kind::MESSAGE_EDIT);
assert_eq!(ev.reference_id.as_deref(), Some(target.as_str()));
assert_eq!(ev.content, "edited!");
}
_ => panic!("expected an edit update"),
}
let mallory = Keys::generate();
let hijack = seal_typed(&mallory, event_kind::COMMUNITY_EDIT, "hijacked", 3, &target);
assert!(process_incoming(&mut state, &hijack, &test_channel(), &alice.public_key()).is_none());
assert_eq!(state.find_message(&target).unwrap().1.content, "edited!");
}
#[test]
fn cooperative_delete_only_honored_from_original_author() {
use crate::stored_event::event_kind;
let mut state = ChatState::new();
let alice = Keys::generate();
let mallory = Keys::generate();
let target = ingest_msg(&mut state, &alice, "secret", 1, &alice);
let hijack = seal_typed(&mallory, event_kind::COMMUNITY_DELETE, "", 2, &target);
assert!(process_incoming(&mut state, &hijack, &test_channel(), &alice.public_key()).is_none());
assert!(state.find_message(&target).is_some(), "non-author delete must not remove");
let del = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 3, &target);
match process_incoming(&mut state, &del, &test_channel(), &alice.public_key()) {
Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
_ => panic!("expected a removal"),
}
assert!(state.find_message(&target).is_none(), "message gone after author delete");
let replay = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 4, &target);
assert!(process_incoming(&mut state, &replay, &test_channel(), &alice.public_key()).is_none());
}
#[test]
fn dissolved_community_still_honors_an_own_message_delete() {
use crate::stored_event::event_kind;
let mut state = ChatState::new();
let alice = Keys::generate();
let target = ingest_msg(&mut state, &alice, "alice's own message", 1, &alice);
let mut ch = test_channel();
ch.dissolved = true;
let del = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 2, &target);
match process_incoming(&mut state, &del, &ch, &alice.public_key()) {
Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
_ => panic!("a self-delete must be honored in a dissolved community"),
}
assert!(state.find_message(&target).is_none(), "own message scrubbed from the dead community");
}
#[test]
fn admin_moderation_hide_removes_any_message() {
let owner = Keys::generate();
let admin = Keys::generate(); let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
let alice = Keys::generate(); let mallory = Keys::generate(); let mut state = ChatState::new();
let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
let hijack = seal_hide(&c, &mallory, &target, 2, None);
assert!(process_incoming(&mut state, &hijack, &c, &alice.public_key()).is_none());
assert!(state.find_message(&target).is_some(), "unprivileged hide rejected");
let hide = seal_hide(&c, &admin, &target, 3, Some(&cite));
match process_incoming(&mut state, &hide, &c, &alice.public_key()) {
Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
_ => panic!("expected admin moderation-hide to remove the message"),
}
assert!(state.find_message(&target).is_none(), "admin hide removed the message");
}
#[test]
fn admin_hide_without_a_citation_is_dropped() {
let owner = Keys::generate();
let admin = Keys::generate();
let (_tmp, _guard, c, _cite) = db_roster_channel(&owner, &admin.public_key());
let alice = Keys::generate();
let mut state = ChatState::new();
let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
let hide = seal_hide(&c, &admin, &target, 2, None); assert!(process_incoming(&mut state, &hide, &c, &alice.public_key()).is_none());
assert!(state.find_message(&target).is_some(), "an uncited admin hide is dropped");
}
#[test]
fn hide_citing_an_unsynced_grant_version_is_dropped() {
let owner = Keys::generate();
let admin = Keys::generate();
let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
let alice = Keys::generate();
let mut state = ChatState::new();
let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
let ahead = AuthorityCitation { version: 2, ..cite };
let hide = seal_hide(&c, &admin, &target, 2, Some(&ahead));
assert!(process_incoming(&mut state, &hide, &c, &alice.public_key()).is_none());
assert!(state.find_message(&target).is_some(), "a hide citing an unsynced version is dropped");
}
#[test]
fn hide_with_a_forged_citation_hash_is_dropped() {
let owner = Keys::generate();
let admin = Keys::generate();
let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
let alice = Keys::generate();
let mut state = ChatState::new();
let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
let forged = AuthorityCitation { edition_hash: [0xEE; 32], ..cite };
let hide = seal_hide(&c, &admin, &target, 2, Some(&forged));
assert!(process_incoming(&mut state, &hide, &c, &alice.public_key()).is_none());
assert!(state.find_message(&target).is_some(), "a forged-hash citation is dropped");
}
#[test]
fn protected_owner_cannot_be_moderation_hidden_but_others_can() {
let owner = Keys::generate(); let admin = Keys::generate(); let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
let mut state = ChatState::new();
let owners_msg = ingest_msg_in(&mut state, &c, &owner, "owner speaks", 1, &owner);
let hide_owner = seal_hide(&c, &admin, &owners_msg, 2, Some(&cite));
assert!(process_incoming(&mut state, &hide_owner, &c, &owner.public_key()).is_none());
assert!(state.find_message(&owners_msg).is_some(), "owner's message is protected");
let member = Keys::generate();
let members_msg = ingest_msg_in(&mut state, &c, &member, "member speaks", 3, &owner);
let hide_member = seal_hide(&c, &admin, &members_msg, 4, Some(&cite));
match process_incoming(&mut state, &hide_member, &c, &owner.public_key()) {
Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, members_msg),
_ => panic!("a non-protected member's message should be hideable"),
}
}
#[test]
fn admin_hide_of_absent_target_defers_until_resident() {
let owner = Keys::generate();
let admin = Keys::generate();
let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
let mut state = ChatState::new();
let absent_target = "f".repeat(64);
let hide = seal_hide(&c, &admin, &absent_target, 1, Some(&cite));
assert!(
process_incoming(&mut state, &hide, &c, &Keys::generate().public_key()).is_none(),
"a hide of an absent target defers (None) rather than falsely tombstoning + self-deduping",
);
let mallory = Keys::generate();
let hijack = seal_hide(&c, &mallory, &absent_target, 2, None);
assert!(process_incoming(&mut state, &hijack, &c, &mallory.public_key()).is_none());
let uncited = seal_hide(&c, &admin, &absent_target, 3, None);
assert!(
process_incoming(&mut state, &uncited, &c, &Keys::generate().public_key()).is_none(),
"an admin's uncited hide of an unknown target is dropped (pinned gates the author-unknown path)"
);
}
#[tokio::test]
async fn out_of_window_hide_authorizes_against_db_author() {
use crate::types::Message;
use nostr_sdk::ToBech32;
let owner = Keys::generate();
let admin = Keys::generate(); let member = Keys::generate();
let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
let owner_msg = "a".repeat(64);
let member_msg = "b".repeat(64);
let mk = |id: &str, author: &Keys, at: u64| {
let mut m = Message::default();
m.id = id.to_string();
m.npub = Some(author.public_key().to_bech32().unwrap());
m.at = at;
m
};
crate::db::events::save_message("chatoow", &mk(&owner_msg, &owner, 1)).await.unwrap();
crate::db::events::save_message("chatoow", &mk(&member_msg, &member, 2)).await.unwrap();
let mut state = ChatState::new();
let hide_owner = seal_hide(&c, &admin, &owner_msg, 3, Some(&cite));
assert!(
process_incoming(&mut state, &hide_owner, &c, &member.public_key()).is_none(),
"owner's paged-out message must not be hideable by an admin"
);
let hide_member = seal_hide(&c, &admin, &member_msg, 4, Some(&cite));
match process_incoming(&mut state, &hide_member, &c, &owner.public_key()) {
Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, member_msg),
_ => panic!("admin should hide a member's paged-out message"),
}
let member_msg2 = "c".repeat(64);
crate::db::events::save_message("chatoow", &mk(&member_msg2, &member, 5)).await.unwrap();
let mut sealed = c.clone();
sealed.dissolved = true;
let hide_sealed = seal_hide(&sealed, &admin, &member_msg2, 6, Some(&cite));
assert!(
process_incoming(&mut state, &hide_sealed, &sealed, &owner.public_key()).is_none(),
"a dissolved community accepts no moderation-hide, resident or paged-out"
);
let self_del = seal_hide(&sealed, &member, &member_msg2, 7, None);
match process_incoming(&mut state, &self_del, &sealed, &owner.public_key()) {
Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, member_msg2),
_ => panic!("a self-delete of a paged-out message must survive the dissolved seal"),
}
crate::db::close_database();
}
fn seal_kick(channel: &Channel, author: &Keys, target_hex: &str, ms: u64, citation: Option<&AuthorityCitation>) -> Event {
let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
let inner = build_inner_full(
author.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_KICK, target_hex, ms, None, &[], &extra,
)
.sign_with_keys(author)
.unwrap();
seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
}
fn post_join_ms() -> u64 {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
(now + 5) * 1000
}
#[test]
fn cited_admin_kick_of_local_user_yields_self_removal() {
let owner = Keys::generate();
let admin = Keys::generate();
let member = Keys::generate();
let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
let mut state = ChatState::new();
let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), post_join_ms(), Some(&cite));
match process_incoming(&mut state, &kick, &channel, &member.public_key()) {
Some(IncomingEvent::Kicked { community_id }) => assert!(!community_id.is_empty()),
_ => panic!("expected Kicked"),
}
crate::db::close_database();
}
#[test]
fn cited_admin_kick_of_other_member_is_a_leave() {
use nostr_sdk::ToBech32;
let owner = Keys::generate();
let admin = Keys::generate();
let member = Keys::generate();
let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
let mut state = ChatState::new();
let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), post_join_ms(), Some(&cite));
match process_incoming(&mut state, &kick, &channel, &owner.public_key()) {
Some(IncomingEvent::Presence { npub, joined, .. }) => {
assert!(!joined);
assert_eq!(npub, member.public_key().to_bech32().unwrap());
}
_ => panic!("expected leave Presence"),
}
crate::db::close_database();
}
#[test]
fn uncited_kick_is_dropped() {
let owner = Keys::generate();
let admin = Keys::generate();
let member = Keys::generate();
let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
let mut state = ChatState::new();
let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), 1, None);
assert!(process_incoming(&mut state, &kick, &channel, &member.public_key()).is_none(),
"a non-owner kick without a citation is dropped");
crate::db::close_database();
}
#[test]
fn unprivileged_kick_is_dropped() {
let owner = Keys::generate();
let admin = Keys::generate();
let mallory = Keys::generate();
let member = Keys::generate();
let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
let mut state = ChatState::new();
let kick = seal_kick(&channel, &mallory, &member.public_key().to_hex(), 1, Some(&cite));
assert!(process_incoming(&mut state, &kick, &channel, &member.public_key()).is_none(),
"a kick from an unranked actor is dropped");
crate::db::close_database();
}
#[test]
fn kick_of_owner_is_dropped() {
let owner = Keys::generate();
let admin = Keys::generate();
let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
let mut state = ChatState::new();
let kick = seal_kick(&channel, &admin, &owner.public_key().to_hex(), post_join_ms(), Some(&cite));
assert!(process_incoming(&mut state, &kick, &channel, &owner.public_key()).is_none(),
"an admin cannot kick the owner");
crate::db::close_database();
}
#[test]
fn stale_kick_predating_join_is_dropped() {
let owner = Keys::generate();
let admin = Keys::generate();
let member = Keys::generate();
let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
let mut state = ChatState::new();
let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), 1, Some(&cite));
assert!(process_incoming(&mut state, &kick, &channel, &member.public_key()).is_none(),
"a kick older than the current join is dropped");
crate::db::close_database();
}
#[test]
fn webxdc_signals_parse_ad_and_left_and_reject_garbage() {
use crate::stored_event::event_kind;
use nostr_sdk::ToBech32;
let mut state = ChatState::new();
let alice = Keys::generate();
let c = test_channel();
let viewer = Keys::generate();
let mk = |content: &str, ms: u64| {
let inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_WEBXDC, content, ms, None, &[])
.sign_with_keys(&alice).unwrap();
seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
};
let topic = crate::webxdc::mint_topic_id("game-hash", "sender");
let ad = serde_json::json!({ "op": "ad", "topic": topic, "addr": "BASE32NODEADDR" }).to_string();
match process_incoming(&mut state, &mk(&ad, 1), &c, &viewer.public_key()) {
Some(IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, .. }) => {
assert_eq!(npub, alice.public_key().to_bech32().unwrap(), "player is the inner author");
assert_eq!(topic_id, topic);
assert_eq!(node_addr.as_deref(), Some("BASE32NODEADDR"));
}
_ => panic!("expected a webxdc advertisement"),
}
let left = serde_json::json!({ "op": "left", "topic": topic }).to_string();
match process_incoming(&mut state, &mk(&left, 2), &c, &viewer.public_key()) {
Some(IncomingEvent::WebxdcPeer { node_addr, .. }) => {
assert!(node_addr.is_none(), "peer-left carries no addr");
}
_ => panic!("expected a webxdc peer-left"),
}
assert!(
process_incoming(&mut state, &mk(&ad, 3), &c, &alice.public_key()).is_none(),
"own webxdc signal must be ignored"
);
let bad_topic = serde_json::json!({ "op": "ad", "topic": "../../etc", "addr": "X" }).to_string();
assert!(process_incoming(&mut state, &mk(&bad_topic, 4), &c, &viewer.public_key()).is_none());
let bad_op = serde_json::json!({ "op": "explode", "topic": topic }).to_string();
assert!(process_incoming(&mut state, &mk(&bad_op, 5), &c, &viewer.public_key()).is_none());
let no_addr = serde_json::json!({ "op": "ad", "topic": topic }).to_string();
assert!(process_incoming(&mut state, &mk(&no_addr, 6), &c, &viewer.public_key()).is_none());
assert!(process_incoming(&mut state, &mk("not json", 7), &c, &viewer.public_key()).is_none());
}
#[test]
fn typing_indicator_parses_drops_own_echo_and_rejects_garbage() {
use crate::stored_event::event_kind;
use nostr_sdk::ToBech32;
let mut state = ChatState::new();
let alice = Keys::generate();
let c = test_channel();
let viewer = Keys::generate();
let mk = |content: &str, ms: u64| {
let inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_TYPING, content, ms, None, &[])
.sign_with_keys(&alice).unwrap();
seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
};
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
match process_incoming(&mut state, &mk("typing", 1), &c, &viewer.public_key()) {
Some(IncomingEvent::Typing { npub, until }) => {
assert_eq!(npub, alice.public_key().to_bech32().unwrap(), "typer is the inner author");
assert!(until >= now && until <= now + 31, "until is receiver-computed (~now + 30s)");
}
_ => panic!("expected a typing indicator"),
}
assert!(
process_incoming(&mut state, &mk("typing", 2), &c, &alice.public_key()).is_none(),
"own typing signal must be ignored"
);
assert!(process_incoming(&mut state, &mk("nope", 3), &c, &viewer.public_key()).is_none());
}
#[test]
fn presence_announcements_parse_join_and_leave() {
use crate::stored_event::event_kind;
let mut state = ChatState::new();
let alice = Keys::generate();
let c = test_channel();
let viewer = Keys::generate();
let mk = |content: &str, ms: u64| {
let inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_PRESENCE, content, ms, None, &[])
.sign_with_keys(&alice).unwrap();
seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
};
match process_incoming(&mut state, &mk("join", 1), &c, &viewer.public_key()) {
Some(IncomingEvent::Presence { npub, joined, .. }) => {
assert!(joined, "content 'join' → joined");
assert_eq!(npub, alice.public_key().to_bech32().unwrap(), "announcer is the inner author");
}
_ => panic!("expected a join presence"),
}
match process_incoming(&mut state, &mk("leave", 2), &c, &viewer.public_key()) {
Some(IncomingEvent::Presence { joined, invited_by, .. }) => {
assert!(!joined, "content 'leave' → not joined");
assert!(invited_by.is_none(), "a plain leave carries no attribution");
}
_ => panic!("expected a leave presence"),
}
let jean = Keys::generate().public_key().to_bech32().unwrap();
let attributed = serde_json::json!({ "by": jean, "l": "Reddit" }).to_string();
match process_incoming(&mut state, &mk(&attributed, 3), &c, &viewer.public_key()) {
Some(IncomingEvent::Presence { joined, invited_by, invited_label, .. }) => {
assert!(joined, "an attributed-join JSON is still a join");
assert_eq!(invited_by.as_deref(), Some(jean.as_str()), "valid inviter npub surfaced");
assert_eq!(invited_label.as_deref(), Some("Reddit"), "link label surfaced");
}
_ => panic!("expected an attributed join presence"),
}
let forged = serde_json::json!({ "by": "haha not an npub", "l": "x" }).to_string();
match process_incoming(&mut state, &mk(&forged, 4), &c, &viewer.public_key()) {
Some(IncomingEvent::Presence { invited_by, .. }) => assert!(invited_by.is_none(), "forged inviter dropped"),
_ => panic!("expected a join presence"),
}
}
#[test]
fn leave_presence_authored_by_local_npub_yields_self_left() {
use crate::stored_event::event_kind;
let owner = Keys::generate();
let admin = Keys::generate();
let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
let mut state = ChatState::new();
let cid = crate::db::community::community_id_for_channel(&channel.id.to_hex()).unwrap().unwrap();
let cid_bytes = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid));
let leave_ms = crate::db::community::community_created_at_ms(&cid_bytes).unwrap_or(0) + 10_000;
let leave = {
let inner = build_inner_typed(owner.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_PRESENCE, "leave", leave_ms, None, &[])
.sign_with_keys(&owner).unwrap();
seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
};
match process_incoming(&mut state, &leave, &channel, &owner.public_key()) {
Some(IncomingEvent::SelfLeft { community_id }) => assert!(!community_id.is_empty()),
_ => panic!("expected SelfLeft"),
}
crate::db::close_database();
}
#[test]
fn leave_presence_authored_by_another_npub_stays_a_plain_leave() {
use crate::stored_event::event_kind;
use nostr_sdk::ToBech32;
let owner = Keys::generate();
let admin = Keys::generate();
let other = Keys::generate();
let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
let mut state = ChatState::new();
let leave = {
let inner = build_inner_typed(other.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_PRESENCE, "leave", 2, None, &[])
.sign_with_keys(&other).unwrap();
seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
};
match process_incoming(&mut state, &leave, &channel, &owner.public_key()) {
Some(IncomingEvent::Presence { npub, joined, .. }) => {
assert!(!joined);
assert_eq!(npub, other.public_key().to_bech32().unwrap());
}
_ => panic!("expected plain leave Presence"),
}
crate::db::close_database();
}
#[test]
fn self_delete_still_applies_after_keep_keys_teardown() {
let owner = Keys::generate();
let admin = Keys::generate();
let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
let cid = crate::db::community::community_id_for_channel(&channel.id.to_hex()).unwrap().unwrap();
let chan_hex = channel.id.to_hex();
let epoch = channel.epoch.0;
let mut state = ChatState::new();
let target = ingest_msg_in(&mut state, &channel, &owner, "mine", 1, &owner);
crate::db::community::delete_community_retain_keys(&cid).unwrap();
let retained = crate::db::community::held_epoch_key(&cid, &chan_hex, epoch).unwrap()
.expect("epoch key retained after keep-keys teardown");
let mut rebuilt = channel.clone();
rebuilt.key = ChannelKey(retained);
rebuilt.epoch = Epoch(epoch);
let del = seal_hide(&rebuilt, &owner, &target, 2, None);
match process_incoming(&mut state, &del, &rebuilt, &owner.public_key()) {
Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
_ => panic!("expected the self-delete to apply under the retained key"),
}
crate::db::close_database();
}
#[test]
fn banned_author_events_are_dropped_including_presence() {
use crate::stored_event::event_kind;
let mut state = ChatState::new();
let alice = Keys::generate(); let bob = Keys::generate();
let mut c = test_channel();
c.banned = vec![alice.public_key()];
let spam = seal_message(&alice, &c.key, &c.id, c.epoch, "spam", 1).unwrap();
assert!(process_incoming(&mut state, &spam, &c, &bob.public_key()).is_none(), "banned message dropped");
let pres_inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_PRESENCE, "join", 2, None, &[])
.sign_with_keys(&alice).unwrap();
let pres = seal_with_signed_inner(&Keys::generate(), &pres_inner, &c.key, &c.id, c.epoch).unwrap();
assert!(process_incoming(&mut state, &pres, &c, &bob.public_key()).is_none(), "banned presence dropped");
let ok = seal_message(&bob, &c.key, &c.id, c.epoch, "hi", 3).unwrap();
assert!(matches!(process_incoming(&mut state, &ok, &c, &bob.public_key()), Some(IncomingEvent::NewMessage(_))), "non-banned applied");
}
#[test]
fn cooperative_delete_applies_after_message_in_batch_order() {
use crate::stored_event::event_kind;
let mut state = ChatState::new();
let alice = Keys::generate();
let c = test_channel();
let msg_outer = seal_message(&alice, &c.key, &c.id, c.epoch, "bye", 1).unwrap();
let opened = open_message(&msg_outer, &c.key, &c.id, c.epoch).unwrap();
let inner_id = opened.message_id.to_hex();
let del_outer = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 2, &inner_id);
let applied = process_channel_batch(&mut state, &[del_outer, msg_outer], &c, &alice.public_key());
assert!(applied.iter().any(|e| matches!(e, IncomingEvent::NewMessage(_))));
assert!(applied.iter().any(|e| matches!(e, IncomingEvent::Removed { .. })));
assert!(state.find_message(&inner_id).is_none(), "delete applied despite arriving first");
}
#[test]
fn build_message_sets_mine_and_author() {
let me = Keys::generate();
let opened = opened_from(&me, "hello", 4242);
let msg = build_message(&opened, &me.public_key());
assert_eq!(msg.content, "hello");
assert_eq!(msg.at, 4242);
assert!(msg.mine, "author == me → mine");
assert_eq!(msg.npub, me.public_key().to_bech32().ok());
assert_eq!(msg.id, opened.message_id.to_hex());
let other_view = build_message(&opened, &Keys::generate().public_key());
assert!(!other_view.mine);
}
#[test]
fn ingest_creates_community_chat_and_adds_message() {
let mut state = ChatState::new();
let alice = Keys::generate();
let opened = opened_from(&alice, "gm", 1);
assert!(ingest_message(&mut state, &opened, &alice.public_key()).is_some());
let chat = state.chats.iter().find(|c| c.id == opened.channel_id.to_hex()).expect("chat");
assert!(chat.is_community(), "channel chat must be ChatType::Community");
}
#[test]
fn process_incoming_ingests_valid_drops_foreign() {
let mut state = ChatState::new();
let alice = Keys::generate();
let key = ChannelKey([0x33u8; 32]);
let chan = ChannelId([0x44u8; 32]);
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 };
let outer = seal_message(&alice, &key, &chan, Epoch(0), "real", 1).unwrap();
assert!(process_incoming(&mut state, &outer, &channel, &alice.public_key()).is_some());
assert!(state.chats.iter().any(|c| c.is_community()));
let other_key = ChannelKey([0x99u8; 32]);
let other_chan = ChannelId([0xaau8; 32]);
let foreign = seal_message(&alice, &other_key, &other_chan, Epoch(0), "nope", 1).unwrap();
assert!(process_incoming(&mut state, &foreign, &channel, &alice.public_key()).is_none());
assert_eq!(state.chats.iter().filter(|c| c.is_community()).count(), 1);
}
#[test]
fn ingest_dedups_on_message_id() {
let mut state = ChatState::new();
let alice = Keys::generate();
let opened = opened_from(&alice, "once", 1);
assert!(ingest_message(&mut state, &opened, &alice.public_key()).is_some(), "first add");
assert!(ingest_message(&mut state, &opened, &alice.public_key()).is_none(), "duplicate not re-added");
assert_eq!(state.chats.iter().filter(|c| c.is_community()).count(), 1);
}
#[test]
fn dedup_keys_on_inner_id_across_distinct_outer_events() {
let mut state = ChatState::new();
let alice = Keys::generate();
let key = ChannelKey([0x33u8; 32]);
let chan = ChannelId([0x44u8; 32]);
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 };
let outer_a = seal_message(&alice, &key, &chan, Epoch(0), "dup", 7).unwrap();
let outer_b = seal_message(&alice, &key, &chan, Epoch(0), "dup", 7).unwrap();
assert_ne!(outer_a.id, outer_b.id, "distinct outer events (fresh ephemeral + nonce)");
assert!(process_incoming(&mut state, &outer_a, &channel, &alice.public_key()).is_some());
assert!(
process_incoming(&mut state, &outer_b, &channel, &alice.public_key()).is_none(),
"same inner message id must dedup despite a different outer event"
);
}
#[test]
fn route_incoming_routes_by_pseudonym() {
let mut state = ChatState::new();
let alice = Keys::generate();
let key = ChannelKey([0x33u8; 32]);
let chan = ChannelId([0x44u8; 32]);
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 };
let mut routes = HashMap::new();
routes.insert(channel_pseudonym(&key, &chan, Epoch(0)).to_hex(), channel.clone());
let outer = seal_message(&alice, &key, &chan, Epoch(0), "routed", 1).unwrap();
assert!(route_incoming(&mut state, &outer, &routes, &alice.public_key()).is_some());
let other_key = ChannelKey([0x55u8; 32]);
let other_chan = ChannelId([0x66u8; 32]);
let unrouted = seal_message(&alice, &other_key, &other_chan, Epoch(0), "x", 1).unwrap();
assert!(route_incoming(&mut state, &unrouted, &routes, &alice.public_key()).is_none());
}
#[test]
fn ms_none_falls_back_to_created_at() {
use nostr_sdk::prelude::{EventId, Timestamp, Tags};
let author = Keys::generate();
let opened = OpenedMessage {
message_id: EventId::all_zeros(),
author: author.public_key(),
content: "no ms".into(),
channel_id: ChannelId([1u8; 32]),
epoch: Epoch(0),
ms: None,
created_at: Timestamp::from_secs(1500),
kind: 3300,
attachments: vec![],
citation: None,
wrapper_id: EventId::all_zeros(),
tags: Tags::new(),
};
assert_eq!(build_message(&opened, &author.public_key()).at, 1_500_000);
}
}