use nostr_sdk::prelude::{
Alphabet, Event, Keys, PublicKey, SingleLetterTag, Tag, TagKind, Timestamp, UnsignedEvent,
};
use super::super::{ChannelId, Epoch};
use super::derive::{channel_group_key, GroupKey};
use super::kind;
use super::stream::{self, OpenedStream, SealForm, StreamError};
const TAG_QUOTE: &str = "q";
const TAG_TARGET: &str = "e";
const TAG_TARGET_AUTHOR: &str = "p";
const TAG_TARGET_KIND: &str = "k";
const TAG_EMOJI: &str = "emoji";
#[derive(Debug)]
pub enum ChatError {
Stream(StreamError),
NotEncryptedSealed,
UnknownKind(u16),
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
NoHeldEpoch,
}
impl std::fmt::Display for ChatError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ChatError::Stream(e) => write!(f, "stream: {e}"),
ChatError::NotEncryptedSealed => write!(f, "chat rumor must ride an encrypted seal"),
ChatError::UnknownKind(k) => write!(f, "rumor kind {k} is not a chat-plane kind"),
ChatError::MissingTag(t) => write!(f, "missing chat tag: {t}"),
ChatError::DuplicateTag(t) => write!(f, "duplicate chat tag: {t}"),
ChatError::BadTag(t) => write!(f, "malformed chat tag: {t}"),
ChatError::NoHeldEpoch => write!(f, "wrap author matches no held epoch key"),
}
}
}
impl std::error::Error for ChatError {}
impl From<StreamError> for ChatError {
fn from(e: StreamError) -> Self {
ChatError::Stream(e)
}
}
pub fn chat_group_key(secret: &[u8; 32], channel_id: &ChannelId, epoch: Epoch) -> GroupKey {
channel_group_key(secret, channel_id, epoch)
}
#[allow(clippy::too_many_arguments)]
pub fn build_message_rumor(
author: PublicKey,
channel_id: &ChannelId,
epoch: Epoch,
content: &str,
reply_to: Option<(&str, &str)>,
emoji: &[(&str, &str)],
extra_tags: Vec<Tag>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = stream::channel_binding_tags(channel_id, epoch);
if let Some((parent_id, parent_author)) = reply_to {
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Q)),
[parent_id.to_string(), String::new(), parent_author.to_string()],
));
}
for (shortcode, url) in emoji {
tags.push(emoji_tag(shortcode, url));
}
tags.extend(extra_tags);
stream::build_rumor_ms(kind::MESSAGE, author, content, tags, at_ms)
}
#[allow(clippy::too_many_arguments)]
pub fn build_comment_rumor(
author: PublicKey,
channel_id: &ChannelId,
epoch: Epoch,
content: &str,
parent_id_hex: &str,
parent_kind: u16,
parent_author_hex: &str,
parent_root: Option<(&str, u16, &str)>,
emoji: &[(&str, &str)],
at_ms: u64,
) -> UnsignedEvent {
let mut tags = stream::channel_binding_tags(channel_id, epoch);
let (root_id, root_kind, root_author) = parent_root.unwrap_or((parent_id_hex, parent_kind, parent_author_hex));
tags.push(Tag::custom(TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::K)), [root_kind.to_string()]));
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::E)),
[root_id.to_string(), String::new(), root_author.to_string()],
));
tags.push(Tag::custom(TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::P)), [root_author.to_string()]));
tags.push(Tag::custom(TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::K)), [parent_kind.to_string()]));
tags.push(Tag::custom(
TagKind::e(),
[parent_id_hex.to_string(), String::new(), parent_author_hex.to_string()],
));
tags.push(Tag::custom(TagKind::p(), [parent_author_hex.to_string()]));
for (shortcode, url) in emoji {
tags.push(emoji_tag(shortcode, url));
}
stream::build_rumor_ms(kind::COMMENT, author, content, tags, at_ms)
}
#[allow(clippy::too_many_arguments)]
pub fn build_reaction_rumor(
author: PublicKey,
channel_id: &ChannelId,
epoch: Epoch,
target_rumor_id_hex: &str,
target_author_hex: &str,
target_kind: u16,
emoji_content: &str,
emoji: Option<(&str, &str)>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = stream::channel_binding_tags(channel_id, epoch);
tags.push(Tag::custom(TagKind::e(), [target_rumor_id_hex.to_string()]));
tags.push(Tag::custom(TagKind::p(), [target_author_hex.to_string()]));
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::K)),
[target_kind.to_string()],
));
if let Some((shortcode, url)) = emoji {
tags.push(emoji_tag(shortcode, url));
}
stream::build_rumor_ms(kind::REACTION, author, emoji_content, tags, at_ms)
}
pub fn build_delete_rumor(
author: PublicKey,
channel_id: &ChannelId,
epoch: Epoch,
target_rumor_id_hex: &str,
target_kind: u16,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = stream::channel_binding_tags(channel_id, epoch);
tags.push(Tag::custom(TagKind::e(), [target_rumor_id_hex.to_string()]));
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::K)),
[target_kind.to_string()],
));
stream::build_rumor_ms(kind::DELETE, author, "", tags, at_ms)
}
pub fn build_edit_rumor(
author: PublicKey,
channel_id: &ChannelId,
epoch: Epoch,
target_rumor_id_hex: &str,
new_content: &str,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = stream::channel_binding_tags(channel_id, epoch);
tags.push(Tag::custom(TagKind::e(), [target_rumor_id_hex.to_string()]));
stream::build_rumor_ms(kind::EDIT, author, new_content, tags, at_ms)
}
pub fn build_webxdc_rumor(
author: PublicKey,
channel_id: &ChannelId,
epoch: Epoch,
content: &str,
extra_tags: Vec<Tag>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = stream::channel_binding_tags(channel_id, epoch);
tags.extend(extra_tags);
stream::build_rumor_ms(kind::WEBXDC, author, content, tags, at_ms)
}
pub fn build_typing_rumor(author: PublicKey, channel_id: &ChannelId, epoch: Epoch, at_ms: u64) -> UnsignedEvent {
let tags = stream::channel_binding_tags(channel_id, epoch);
stream::build_rumor_ms(kind::TYPING, author, "", tags, at_ms)
}
pub fn seal_chat_rumor(
rumor: &UnsignedEvent,
group: &GroupKey,
author_keys: &Keys,
wrap_at: Timestamp,
ephemeral: bool,
) -> Result<(Event, Keys), ChatError> {
let k = rumor.kind.as_u16();
if !is_chat_kind(k) {
return Err(ChatError::UnknownKind(k));
}
let seal = stream::build_seal(rumor, SealForm::Encrypted, group, author_keys)?;
let wrap_kind = if ephemeral { stream::KIND_WRAP_EPHEMERAL } else { stream::KIND_WRAP };
Ok(stream::wrap_seal(&seal, group, wrap_kind, wrap_at)?)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplyRef {
pub id: [u8; 32],
pub author: Option<PublicKey>,
}
#[derive(Debug, Clone)]
pub enum ChatEvent {
Message {
opened: OpenedStream,
reply_to: Option<ReplyRef>,
emoji: Vec<(String, String)>,
},
Reaction {
opened: OpenedStream,
target: [u8; 32],
target_author: PublicKey,
emoji: String,
emoji_url: Option<String>,
},
Delete {
opened: OpenedStream,
target: [u8; 32],
target_kind: Option<u16>,
},
Edit {
opened: OpenedStream,
target: [u8; 32],
new_content: String,
},
Webxdc { opened: OpenedStream },
Typing { opened: OpenedStream },
}
impl ChatEvent {
pub fn opened(&self) -> &OpenedStream {
match self {
ChatEvent::Message { opened, .. }
| ChatEvent::Reaction { opened, .. }
| ChatEvent::Delete { opened, .. }
| ChatEvent::Edit { opened, .. }
| ChatEvent::Webxdc { opened }
| ChatEvent::Typing { opened } => opened,
}
}
}
pub fn open_chat_event(
wrap: &Event,
group: &GroupKey,
channel_id: &ChannelId,
epoch: Epoch,
) -> Result<ChatEvent, ChatError> {
let opened = stream::open_wrap(wrap, group)?;
if opened.seal_form != SealForm::Encrypted {
return Err(ChatError::NotEncryptedSealed);
}
stream::check_channel_binding(&opened.rumor, channel_id, epoch)?;
parse_chat_rumor(opened)
}
pub fn open_chat_event_multi(
wrap: &Event,
held: &[(Epoch, [u8; 32])],
channel_id: &ChannelId,
) -> Result<(ChatEvent, Epoch), ChatError> {
for (epoch, secret) in held {
let group = channel_group_key(secret, channel_id, *epoch);
if wrap.pubkey == group.pk() {
return open_chat_event(wrap, &group, channel_id, *epoch).map(|ev| (ev, *epoch));
}
}
Err(ChatError::NoHeldEpoch)
}
fn is_chat_kind(k: u16) -> bool {
matches!(
k,
kind::MESSAGE | kind::COMMENT | kind::REACTION | kind::DELETE | kind::EDIT | kind::WEBXDC | kind::TYPING
)
}
fn parse_chat_rumor(opened: OpenedStream) -> Result<ChatEvent, ChatError> {
match opened.rumor.kind.as_u16() {
kind::MESSAGE => {
let reply_to = match unique_tag(&opened.rumor, TAG_QUOTE)? {
None => None,
Some(s) => {
let id = decode_id32(value_of(s, TAG_QUOTE)?, TAG_QUOTE)?;
let author = match s.get(3).map(String::as_str).filter(|a| !a.is_empty()) {
Some(hex) => Some(PublicKey::from_hex(hex).map_err(|_| ChatError::BadTag(TAG_QUOTE))?),
None => None,
};
Some(ReplyRef { id, author })
}
};
let emoji = collect_emoji(&opened.rumor);
Ok(ChatEvent::Message { opened, reply_to, emoji })
}
kind::COMMENT => {
let reply_to = match unique_tag(&opened.rumor, TAG_TARGET)? {
None => None, Some(s) => {
let id = decode_id32(value_of(s, TAG_TARGET)?, TAG_TARGET)?;
let author = match s.get(3).map(String::as_str).filter(|a| !a.is_empty()) {
Some(hex) => Some(PublicKey::from_hex(hex).map_err(|_| ChatError::BadTag(TAG_TARGET))?),
None => None,
};
Some(ReplyRef { id, author })
}
};
let emoji = collect_emoji(&opened.rumor);
Ok(ChatEvent::Message { opened, reply_to, emoji })
}
kind::REACTION => {
let target = decode_id32(required_tag(&opened.rumor, TAG_TARGET)?, TAG_TARGET)?;
let target_author = PublicKey::from_hex(required_tag(&opened.rumor, TAG_TARGET_AUTHOR)?)
.map_err(|_| ChatError::BadTag(TAG_TARGET_AUTHOR))?;
let emoji_url = collect_emoji(&opened.rumor).into_iter().next().map(|(_, url)| url);
let emoji = opened.rumor.content.clone();
Ok(ChatEvent::Reaction { opened, target, target_author, emoji, emoji_url })
}
kind::DELETE => {
let target = decode_id32(required_tag(&opened.rumor, TAG_TARGET)?, TAG_TARGET)?;
let target_kind = match unique_tag(&opened.rumor, TAG_TARGET_KIND)? {
None => None,
Some(s) => Some(
value_of(s, TAG_TARGET_KIND)?
.parse::<u16>()
.map_err(|_| ChatError::BadTag(TAG_TARGET_KIND))?,
),
};
Ok(ChatEvent::Delete { opened, target, target_kind })
}
kind::EDIT => {
let target = decode_id32(required_tag(&opened.rumor, TAG_TARGET)?, TAG_TARGET)?;
let new_content = opened.rumor.content.clone();
Ok(ChatEvent::Edit { opened, target, new_content })
}
kind::WEBXDC => Ok(ChatEvent::Webxdc { opened }),
kind::TYPING => Ok(ChatEvent::Typing { opened }),
k => Err(ChatError::UnknownKind(k)),
}
}
fn emoji_tag(shortcode: &str, url: &str) -> Tag {
Tag::custom(TagKind::Custom(TAG_EMOJI.into()), [shortcode.to_string(), url.to_string()])
}
fn unique_tag<'a>(rumor: &'a UnsignedEvent, name: &'static str) -> Result<Option<&'a [String]>, ChatError> {
let mut found: Option<&[String]> = None;
for t in rumor.tags.iter() {
let s = t.as_slice();
if s.first().map(|n| n == name).unwrap_or(false) {
if found.is_some() {
return Err(ChatError::DuplicateTag(name));
}
found = Some(s);
}
}
Ok(found)
}
fn required_tag<'a>(rumor: &'a UnsignedEvent, name: &'static str) -> Result<&'a str, ChatError> {
let s = unique_tag(rumor, name)?.ok_or(ChatError::MissingTag(name))?;
value_of(s, name)
}
fn value_of<'a>(slice: &'a [String], name: &'static str) -> Result<&'a str, ChatError> {
slice.get(1).map(String::as_str).ok_or(ChatError::BadTag(name))
}
fn decode_id32(hex: &str, field: &'static str) -> Result<[u8; 32], ChatError> {
if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(ChatError::BadTag(field));
}
Ok(crate::simd::hex::hex_to_bytes_32(hex))
}
fn collect_emoji(rumor: &UnsignedEvent) -> Vec<(String, String)> {
rumor
.tags
.iter()
.filter_map(|t| {
let s = t.as_slice();
(s.len() >= 3 && s[0] == TAG_EMOJI).then(|| (s[1].clone(), s[2].clone()))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const AT: u64 = 1_686_840_217_417;
const WRAP_AT: Timestamp = Timestamp::from_secs(1_700_000_000);
fn chan() -> ChannelId {
ChannelId([0xab; 32])
}
fn secret() -> [u8; 32] {
[7u8; 32]
}
fn group() -> GroupKey {
chat_group_key(&secret(), &chan(), Epoch(0))
}
fn open(wrap: &Event) -> Result<ChatEvent, ChatError> {
open_chat_event(wrap, &group(), &chan(), Epoch(0))
}
fn seal(rumor: &UnsignedEvent, author: &Keys) -> Event {
seal_chat_rumor(rumor, &group(), author, WRAP_AT, false).unwrap().0
}
#[test]
fn message_round_trip_carries_reply_emoji_and_extra_tags_verbatim() {
let author = Keys::generate();
let parent = Keys::generate();
let parent_id = "aa".repeat(32);
let imeta = Tag::custom(
TagKind::Custom("imeta".into()),
["url https://x/f.png".to_string(), "m image/png".to_string()],
);
let rumor = build_message_rumor(
author.public_key(),
&chan(),
Epoch(0),
"welcome :catJAM:",
Some((&parent_id, &parent.public_key().to_hex())),
&[("catJAM", "https://x/cat.gif")],
vec![imeta.clone()],
AT,
);
let wrap = seal(&rumor, &author);
let ChatEvent::Message { opened, reply_to, emoji } = open(&wrap).unwrap() else {
panic!("expected a Message");
};
assert_eq!(opened.author, author.public_key());
assert_eq!(opened.rumor.content, "welcome :catJAM:");
assert_eq!(opened.at_ms, AT);
let reply = reply_to.expect("reply parses back");
assert_eq!(reply.id, [0xaa; 32]);
assert_eq!(reply.author, Some(parent.public_key()));
assert_eq!(emoji, vec![("catJAM".to_string(), "https://x/cat.gif".to_string())]);
assert!(opened.rumor.tags.iter().any(|t| t.as_slice() == imeta.as_slice()));
}
#[test]
fn message_without_q_has_no_reply() {
let author = Keys::generate();
let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), "hi", None, &[], vec![], AT);
let ChatEvent::Message { reply_to, emoji, .. } = open(&seal(&rumor, &author)).unwrap() else {
panic!("expected a Message");
};
assert_eq!(reply_to, None);
assert!(emoji.is_empty());
}
#[test]
fn a_threaded_reply_round_trips_as_a_message_with_its_parent_as_reply_context() {
let author = Keys::generate();
let root_author = Keys::generate();
let root_id = "cd".repeat(32);
let rumor = build_comment_rumor(
author.public_key(),
&chan(),
Epoch(0),
"replying in the thread!",
&root_id,
kind::MESSAGE,
&root_author.public_key().to_hex(),
None, &[],
AT,
);
assert!(rumor.tags.iter().any(|t| t.as_slice() == ["K", "9"]));
assert!(rumor.tags.iter().any(|t| t.as_slice()[0] == "E" && t.as_slice()[1] == root_id));
assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "9"]));
let ChatEvent::Message { opened, reply_to, .. } = open(&seal(&rumor, &author)).unwrap() else {
panic!("a threaded reply parses as a Message");
};
assert_eq!(opened.rumor.kind.as_u16(), kind::COMMENT, "the wire kind is preserved on the rumor");
let reply = reply_to.expect("the immediate parent is the reply context");
assert_eq!(reply.id, [0xcd; 32]);
assert_eq!(reply.author, Some(root_author.public_key()));
}
#[test]
fn an_armada_shaped_threaded_reply_parses_verbatim() {
let author = Keys::generate();
let root_author = Keys::generate();
let parent_author = Keys::generate();
let root_id = "ef".repeat(32);
let parent_id = "12".repeat(32);
let tags = vec![
Tag::custom(TagKind::Custom("channel".into()), [crate::simd::hex::bytes_to_hex_32(&chan().0)]),
Tag::custom(TagKind::Custom("epoch".into()), ["0".to_string()]),
Tag::custom(TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::K)), ["9".to_string()]),
Tag::custom(
TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::E)),
[root_id.clone(), String::new(), root_author.public_key().to_hex()],
),
Tag::custom(TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::P)), [root_author.public_key().to_hex()]),
Tag::custom(TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::K)), ["1111".to_string()]),
Tag::custom(TagKind::e(), [parent_id.clone(), String::new(), parent_author.public_key().to_hex()]),
Tag::custom(TagKind::p(), [parent_author.public_key().to_hex()]),
];
let rumor = stream::build_rumor_ms(kind::COMMENT, author.public_key(), "nested reply", tags, AT);
let ChatEvent::Message { reply_to, .. } = open(&seal(&rumor, &author)).unwrap() else {
panic!("expected a Message");
};
let reply = reply_to.expect("parent parses");
assert_eq!(reply.id, [0x12; 32]);
assert_eq!(reply.author, Some(parent_author.public_key()));
}
#[test]
fn a_nested_comment_inherits_its_root_tags_verbatim() {
let author = Keys::generate();
let root_id = "ab".repeat(32);
let root_author_hex = Keys::generate().public_key().to_hex();
let parent_id = "cd".repeat(32);
let parent_author_hex = Keys::generate().public_key().to_hex();
let rumor = build_comment_rumor(
author.public_key(),
&chan(),
Epoch(0),
"deep",
&parent_id,
kind::COMMENT, &parent_author_hex,
Some((&root_id, kind::MESSAGE, &root_author_hex)),
&[],
AT,
);
assert!(rumor.tags.iter().any(|t| t.as_slice()[0] == "E" && t.as_slice()[1] == root_id));
assert!(rumor.tags.iter().any(|t| t.as_slice() == ["K", "9"]));
assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "1111"]));
assert!(rumor.tags.iter().any(|t| t.as_slice()[0] == "e" && t.as_slice()[1] == parent_id));
}
#[test]
fn a_comment_with_duplicate_parent_tags_is_rejected() {
let author = Keys::generate();
let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
for id in ["ab", "ff"] {
tags.push(Tag::custom(TagKind::e(), [
id.repeat(32),
String::new(),
Keys::generate().public_key().to_hex(),
]));
}
let rumor = stream::build_rumor_ms(kind::COMMENT, author.public_key(), "ambiguous", tags, AT);
let got = open(&seal(&rumor, &author));
assert!(matches!(&got, Err(ChatError::DuplicateTag("e"))), "got: {got:?}");
}
#[test]
fn a_reaction_to_a_threaded_reply_carries_k_1111() {
let author = Keys::generate();
let rumor = build_reaction_rumor(
author.public_key(),
&chan(),
Epoch(0),
&"bc".repeat(32),
&Keys::generate().public_key().to_hex(),
kind::COMMENT,
"🔥",
None,
AT,
);
assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "1111"]), "the k tag names the target's kind");
assert!(matches!(open(&seal(&rumor, &author)).unwrap(), ChatEvent::Reaction { .. }));
}
#[test]
fn reaction_round_trip_and_nip25_shape() {
let author = Keys::generate();
let target_author = Keys::generate();
let target_id = "bc".repeat(32);
let rumor = build_reaction_rumor(
author.public_key(),
&chan(),
Epoch(0),
&target_id,
&target_author.public_key().to_hex(),
kind::MESSAGE,
"🔥",
None,
AT,
);
assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "9"]));
let ChatEvent::Reaction { opened, target, target_author: ta, emoji, emoji_url } =
open(&seal(&rumor, &author)).unwrap()
else {
panic!("expected a Reaction");
};
assert_eq!(opened.author, author.public_key());
assert_eq!(target, [0xbc; 32]);
assert_eq!(ta, target_author.public_key());
assert_eq!(emoji, "🔥");
assert_eq!(emoji_url, None);
assert_eq!(opened.at_ms, AT);
}
#[test]
fn reaction_custom_emoji_carries_the_nip30_url() {
let author = Keys::generate();
let rumor = build_reaction_rumor(
author.public_key(),
&chan(),
Epoch(0),
&"bc".repeat(32),
&Keys::generate().public_key().to_hex(),
kind::MESSAGE,
":catJAM:",
Some(("catJAM", "https://x/cat.gif")),
AT,
);
let ChatEvent::Reaction { emoji, emoji_url, .. } = open(&seal(&rumor, &author)).unwrap() else {
panic!("expected a Reaction");
};
assert_eq!(emoji, ":catJAM:");
assert_eq!(emoji_url, Some("https://x/cat.gif".to_string()));
}
#[test]
fn delete_round_trip_and_optional_target_kind() {
let author = Keys::generate();
let rumor = build_delete_rumor(author.public_key(), &chan(), Epoch(0), &"cd".repeat(32), kind::MESSAGE, AT);
let ChatEvent::Delete { target, target_kind, .. } = open(&seal(&rumor, &author)).unwrap() else {
panic!("expected a Delete");
};
assert_eq!(target, [0xcd; 32]);
assert_eq!(target_kind, Some(kind::MESSAGE));
let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
tags.push(Tag::custom(TagKind::e(), ["cd".repeat(32)]));
let bare = stream::build_rumor_ms(kind::DELETE, author.public_key(), "", tags, AT);
let ChatEvent::Delete { target_kind, .. } = open(&seal(&bare, &author)).unwrap() else {
panic!("expected a Delete");
};
assert_eq!(target_kind, None);
}
#[test]
fn edit_round_trip_replaces_content() {
let author = Keys::generate();
let rumor = build_edit_rumor(author.public_key(), &chan(), Epoch(0), &"de".repeat(32), "fixed the typo", AT);
let ChatEvent::Edit { opened, target, new_content } = open(&seal(&rumor, &author)).unwrap() else {
panic!("expected an Edit");
};
assert_eq!(opened.author, author.public_key());
assert_eq!(target, [0xde; 32]);
assert_eq!(new_content, "fixed the typo");
}
#[test]
fn webxdc_round_trip_is_opaque() {
let author = Keys::generate();
let app_tag = Tag::custom(TagKind::Custom("xdc".into()), ["state-update".to_string()]);
let rumor = build_webxdc_rumor(
author.public_key(),
&chan(),
Epoch(0),
"{\"move\":\"e4\"}",
vec![app_tag.clone()],
AT,
);
let ChatEvent::Webxdc { opened } = open(&seal(&rumor, &author)).unwrap() else {
panic!("expected a Webxdc");
};
assert_eq!(opened.rumor.content, "{\"move\":\"e4\"}");
assert!(opened.rumor.tags.iter().any(|t| t.as_slice() == app_tag.as_slice()));
}
#[test]
fn typing_rides_ephemeral_and_wrap_tier_is_not_content_authority() {
let author = Keys::generate();
let typing = build_typing_rumor(author.public_key(), &chan(), Epoch(0), AT);
let (wrap, _) = seal_chat_rumor(&typing, &group(), &author, WRAP_AT, true).unwrap();
assert_eq!(wrap.kind.as_u16(), stream::KIND_WRAP_EPHEMERAL);
let ChatEvent::Typing { opened } = open(&wrap).unwrap() else {
panic!("expected a Typing");
};
assert_eq!(opened.author, author.public_key());
assert_eq!(opened.rumor.content, "");
let msg = build_message_rumor(author.public_key(), &chan(), Epoch(0), "live", None, &[], vec![], AT);
let (wrap, _) = seal_chat_rumor(&msg, &group(), &author, WRAP_AT, true).unwrap();
assert!(matches!(open(&wrap), Ok(ChatEvent::Message { .. })));
}
#[test]
fn wrong_channel_and_wrong_epoch_are_rejected() {
let author = Keys::generate();
let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), "x", None, &[], vec![], AT);
let wrap = seal(&rumor, &author);
assert!(matches!(
open_chat_event(&wrap, &group(), &ChannelId([0xcd; 32]), Epoch(0)),
Err(ChatError::Stream(StreamError::ChannelMismatch))
));
let stale = build_message_rumor(author.public_key(), &chan(), Epoch(1), "x", None, &[], vec![], AT);
let wrap = seal(&stale, &author);
assert!(matches!(open(&wrap), Err(ChatError::Stream(StreamError::EpochMismatch))));
}
#[test]
fn reaction_bound_to_channel_a_under_channel_b_key_is_rejected() {
let author = Keys::generate();
let chan_a = ChannelId([0xaa; 32]);
let chan_b = ChannelId([0xbb; 32]);
let group_b = chat_group_key(&secret(), &chan_b, Epoch(0));
let rumor = build_reaction_rumor(
author.public_key(),
&chan_a,
Epoch(0),
&"bc".repeat(32),
&Keys::generate().public_key().to_hex(),
kind::MESSAGE,
"🔥",
None,
AT,
);
let (wrap, _) = seal_chat_rumor(&rumor, &group_b, &author, WRAP_AT, false).unwrap();
assert!(matches!(
open_chat_event(&wrap, &group_b, &chan_b, Epoch(0)),
Err(ChatError::Stream(StreamError::ChannelMismatch))
));
}
#[test]
fn multi_epoch_opens_each_wrap_under_its_own_epoch() {
let author = Keys::generate();
let key0 = [1u8; 32];
let key1 = [2u8; 32];
let held = [(Epoch(0), key0), (Epoch(1), key1)];
let m0 = build_message_rumor(author.public_key(), &chan(), Epoch(0), "before the rekey", None, &[], vec![], AT);
let g0 = chat_group_key(&key0, &chan(), Epoch(0));
let (w0, _) = seal_chat_rumor(&m0, &g0, &author, WRAP_AT, false).unwrap();
let m1 = build_message_rumor(author.public_key(), &chan(), Epoch(1), "after the rekey", None, &[], vec![], AT + 1);
let g1 = chat_group_key(&key1, &chan(), Epoch(1));
let (w1, _) = seal_chat_rumor(&m1, &g1, &author, WRAP_AT, false).unwrap();
let (ev0, e0) = open_chat_event_multi(&w0, &held, &chan()).unwrap();
assert_eq!(e0, Epoch(0));
assert_eq!(ev0.opened().rumor.content, "before the rekey");
let (ev1, e1) = open_chat_event_multi(&w1, &held, &chan()).unwrap();
assert_eq!(e1, Epoch(1));
assert_eq!(ev1.opened().rumor.content, "after the rekey");
}
#[test]
fn multi_epoch_unheld_wrap_is_not_ours() {
let author = Keys::generate();
let held = [(Epoch(0), [1u8; 32]), (Epoch(1), [2u8; 32])];
let m2 = build_message_rumor(author.public_key(), &chan(), Epoch(2), "future", None, &[], vec![], AT);
let g2 = chat_group_key(&[3u8; 32], &chan(), Epoch(2));
let (w2, _) = seal_chat_rumor(&m2, &g2, &author, WRAP_AT, false).unwrap();
assert!(matches!(open_chat_event_multi(&w2, &held, &chan()), Err(ChatError::NoHeldEpoch)));
}
#[test]
fn multi_epoch_cross_epoch_splice_is_rejected() {
let author = Keys::generate();
let key0 = [1u8; 32];
let key1 = [2u8; 32];
let held = [(Epoch(0), key0), (Epoch(1), key1)];
let stale = build_message_rumor(author.public_key(), &chan(), Epoch(0), "replay", None, &[], vec![], AT);
let g1 = chat_group_key(&key1, &chan(), Epoch(1));
let (wrap, _) = seal_chat_rumor(&stale, &g1, &author, WRAP_AT, false).unwrap();
assert!(matches!(
open_chat_event_multi(&wrap, &held, &chan()),
Err(ChatError::Stream(StreamError::EpochMismatch))
));
}
#[test]
fn duplicate_e_tag_on_a_reaction_is_rejected() {
let author = Keys::generate();
let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
tags.push(Tag::custom(TagKind::e(), ["aa".repeat(32)]));
tags.push(Tag::custom(TagKind::e(), ["bb".repeat(32)]));
tags.push(Tag::custom(TagKind::p(), [Keys::generate().public_key().to_hex()]));
let rumor = stream::build_rumor_ms(kind::REACTION, author.public_key(), "+", tags, AT);
assert!(matches!(
open(&seal(&rumor, &author)),
Err(ChatError::DuplicateTag(TAG_TARGET))
));
}
#[test]
fn unknown_rumor_kind_is_rejected_on_both_sides() {
let author = Keys::generate();
let tags = stream::channel_binding_tags(&chan(), Epoch(0));
let rumor = stream::build_rumor_ms(3300, author.public_key(), "v1 ghost", tags, AT);
assert!(matches!(
seal_chat_rumor(&rumor, &group(), &author, WRAP_AT, false),
Err(ChatError::UnknownKind(3300))
));
let seal = stream::build_seal(&rumor, SealForm::Encrypted, &group(), &author).unwrap();
let (wrap, _) = stream::wrap_seal(&seal, &group(), stream::KIND_WRAP, WRAP_AT).unwrap();
assert!(matches!(open(&wrap), Err(ChatError::UnknownKind(3300))));
}
#[test]
fn plaintext_sealed_chat_event_is_rejected() {
let author = Keys::generate();
let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), "leaky", None, &[], vec![], AT);
let seal = stream::build_seal(&rumor, SealForm::Plaintext, &group(), &author).unwrap();
let (wrap, _) = stream::wrap_seal(&seal, &group(), stream::KIND_WRAP, WRAP_AT).unwrap();
assert!(matches!(open(&wrap), Err(ChatError::NotEncryptedSealed)));
}
#[test]
fn malformed_targets_are_errors_not_panics() {
let author = Keys::generate();
let rumor = build_reaction_rumor(
author.public_key(),
&chan(),
Epoch(0),
&"zz".repeat(32),
&Keys::generate().public_key().to_hex(),
kind::MESSAGE,
"+",
None,
AT,
);
assert!(matches!(open(&seal(&rumor, &author)), Err(ChatError::BadTag(TAG_TARGET))));
let rumor = build_message_rumor(
author.public_key(),
&chan(),
Epoch(0),
"x",
Some(("abcd", &Keys::generate().public_key().to_hex())),
&[],
vec![],
AT,
);
assert!(matches!(open(&seal(&rumor, &author)), Err(ChatError::BadTag(TAG_QUOTE))));
let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
tags.push(Tag::custom(TagKind::e(), ["cd".repeat(32)]));
tags.push(Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::K)),
["nine".to_string()],
));
let rumor = stream::build_rumor_ms(kind::DELETE, author.public_key(), "", tags, AT);
assert!(matches!(open(&seal(&rumor, &author)), Err(ChatError::BadTag(TAG_TARGET_KIND))));
let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
tags.push(Tag::custom(TagKind::e(), ["cd".repeat(32)]));
let rumor = stream::build_rumor_ms(kind::REACTION, author.public_key(), "+", tags, AT);
assert!(matches!(
open(&seal(&rumor, &author)),
Err(ChatError::MissingTag(TAG_TARGET_AUTHOR))
));
}
}