1use nostr_sdk::prelude::{Event, Keys, PublicKey, Tag, Timestamp, UnsignedEvent,
30};
31
32use super::super::{ChannelId, Epoch};
33use super::derive::{channel_group_key, GroupKey};
34use super::kind;
35use super::stream::{self, OpenedStream, SealForm, StreamError};
36
37const TAG_QUOTE: &str = "q";
38const TAG_TARGET: &str = "e";
39const TAG_TARGET_AUTHOR: &str = "p";
40const TAG_TARGET_KIND: &str = "k";
41const TAG_EMOJI: &str = "emoji";
42
43#[derive(Debug)]
45pub enum ChatError {
46 Stream(StreamError),
47 NotEncryptedSealed,
50 UnknownKind(u16),
53 MissingTag(&'static str),
54 DuplicateTag(&'static str),
57 BadTag(&'static str),
59 NoHeldEpoch,
61}
62
63impl std::fmt::Display for ChatError {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 match self {
66 ChatError::Stream(e) => write!(f, "stream: {e}"),
67 ChatError::NotEncryptedSealed => write!(f, "chat rumor must ride an encrypted seal"),
68 ChatError::UnknownKind(k) => write!(f, "rumor kind {k} is not a chat-plane kind"),
69 ChatError::MissingTag(t) => write!(f, "missing chat tag: {t}"),
70 ChatError::DuplicateTag(t) => write!(f, "duplicate chat tag: {t}"),
71 ChatError::BadTag(t) => write!(f, "malformed chat tag: {t}"),
72 ChatError::NoHeldEpoch => write!(f, "wrap author matches no held epoch key"),
73 }
74 }
75}
76
77impl std::error::Error for ChatError {}
78
79impl From<StreamError> for ChatError {
80 fn from(e: StreamError) -> Self {
81 ChatError::Stream(e)
82 }
83}
84
85pub fn chat_group_key(secret: &[u8; 32], channel_id: &ChannelId, epoch: Epoch) -> GroupKey {
93 channel_group_key(secret, channel_id, epoch)
94}
95
96#[allow(clippy::too_many_arguments)]
107pub fn build_message_rumor(
108 author: PublicKey,
109 channel_id: &ChannelId,
110 epoch: Epoch,
111 content: &str,
112 reply_to: Option<(&str, &str)>,
113 emoji: &[(&str, &str)],
114 extra_tags: Vec<Tag>,
115 at_ms: u64,
116) -> UnsignedEvent {
117 let mut tags = stream::channel_binding_tags(channel_id, epoch);
118 if let Some((parent_id, parent_author)) = reply_to {
119 tags.push(Tag::custom(
120 "q",
121 [parent_id.to_string(), String::new(), parent_author.to_string()],
122 ));
123 }
124 for (shortcode, url) in emoji {
125 tags.push(emoji_tag(shortcode, url));
126 }
127 tags.extend(extra_tags);
128 stream::build_rumor_ms(kind::MESSAGE, author, content, tags, at_ms)
129}
130
131pub(crate) fn message_expiration(rumor: &UnsignedEvent) -> Option<u64> {
136 rumor.tags.iter().find_map(|tag| {
137 let s = tag.as_slice();
138 if s.len() >= 2 && s[0] == "expiration" {
139 s[1].parse::<u64>().ok()
140 } else {
141 None
142 }
143 })
144}
145
146#[allow(clippy::too_many_arguments)]
153pub fn build_comment_rumor(
154 author: PublicKey,
155 channel_id: &ChannelId,
156 epoch: Epoch,
157 content: &str,
158 parent_id_hex: &str,
159 parent_kind: u16,
160 parent_author_hex: &str,
161 parent_root: Option<(&str, u16, &str)>,
162 emoji: &[(&str, &str)],
163 at_ms: u64,
164) -> UnsignedEvent {
165 let mut tags = stream::channel_binding_tags(channel_id, epoch);
166 let (root_id, root_kind, root_author) = parent_root.unwrap_or((parent_id_hex, parent_kind, parent_author_hex));
167 tags.push(Tag::custom("K", [root_kind.to_string()]));
168 tags.push(Tag::custom(
169 "E",
170 [root_id.to_string(), String::new(), root_author.to_string()],
171 ));
172 tags.push(Tag::custom("P", [root_author.to_string()]));
173 tags.push(Tag::custom("k", [parent_kind.to_string()]));
174 tags.push(Tag::custom(
175 "e",
176 [parent_id_hex.to_string(), String::new(), parent_author_hex.to_string()],
177 ));
178 tags.push(Tag::custom("p", [parent_author_hex.to_string()]));
179 for (shortcode, url) in emoji {
180 tags.push(emoji_tag(shortcode, url));
181 }
182 stream::build_rumor_ms(kind::COMMENT, author, content, tags, at_ms)
183}
184
185#[allow(clippy::too_many_arguments)]
191pub fn build_reaction_rumor(
192 author: PublicKey,
193 channel_id: &ChannelId,
194 epoch: Epoch,
195 target_rumor_id_hex: &str,
196 target_author_hex: &str,
197 target_kind: u16,
198 emoji_content: &str,
199 emoji: Option<(&str, &str)>,
200 at_ms: u64,
201) -> UnsignedEvent {
202 let mut tags = stream::channel_binding_tags(channel_id, epoch);
203 tags.push(Tag::custom("e", [target_rumor_id_hex.to_string()]));
204 tags.push(Tag::custom("p", [target_author_hex.to_string()]));
205 tags.push(Tag::custom(
206 "k",
207 [target_kind.to_string()],
208 ));
209 if let Some((shortcode, url)) = emoji {
210 tags.push(emoji_tag(shortcode, url));
211 }
212 stream::build_rumor_ms(kind::REACTION, author, emoji_content, tags, at_ms)
213}
214
215pub fn build_delete_rumor(
224 author: PublicKey,
225 channel_id: &ChannelId,
226 epoch: Epoch,
227 target_rumor_id_hex: &str,
228 target_kind: u16,
229 at_ms: u64,
230 citation: Option<&crate::community::edition::AuthorityCitation>,
231) -> UnsignedEvent {
232 let mut tags = stream::channel_binding_tags(channel_id, epoch);
233 tags.push(Tag::custom("e", [target_rumor_id_hex.to_string()]));
234 tags.push(Tag::custom(
235 "k",
236 [target_kind.to_string()],
237 ));
238 if let Some(c) = citation {
239 tags.push(c.to_tag());
240 }
241 stream::build_rumor_ms(kind::DELETE, author, "", tags, at_ms)
242}
243
244pub fn build_edit_rumor(
248 author: PublicKey,
249 channel_id: &ChannelId,
250 epoch: Epoch,
251 target_rumor_id_hex: &str,
252 new_content: &str,
253 at_ms: u64,
254) -> UnsignedEvent {
255 let mut tags = stream::channel_binding_tags(channel_id, epoch);
256 tags.push(Tag::custom("e", [target_rumor_id_hex.to_string()]));
257 stream::build_rumor_ms(kind::EDIT, author, new_content, tags, at_ms)
258}
259
260pub fn build_webxdc_rumor(
263 author: PublicKey,
264 channel_id: &ChannelId,
265 epoch: Epoch,
266 content: &str,
267 extra_tags: Vec<Tag>,
268 at_ms: u64,
269) -> UnsignedEvent {
270 let mut tags = stream::channel_binding_tags(channel_id, epoch);
271 tags.extend(extra_tags);
272 stream::build_rumor_ms(kind::WEBXDC, author, content, tags, at_ms)
273}
274
275pub fn build_typing_rumor(author: PublicKey, channel_id: &ChannelId, epoch: Epoch, at_ms: u64) -> UnsignedEvent {
278 let tags = stream::channel_binding_tags(channel_id, epoch);
279 stream::build_rumor_ms(kind::TYPING, author, "", tags, at_ms)
280}
281
282pub fn seal_chat_rumor(
292 rumor: &UnsignedEvent,
293 group: &GroupKey,
294 author_keys: &Keys,
295 wrap_at: Timestamp,
296 ephemeral: bool,
297) -> Result<(Event, Keys), ChatError> {
298 let k = rumor.kind.as_u16();
299 if !is_chat_kind(k) {
300 return Err(ChatError::UnknownKind(k));
301 }
302 let seal = stream::build_seal(rumor, SealForm::Encrypted, group, author_keys)?;
303 let wrap_kind = if ephemeral { stream::KIND_WRAP_EPHEMERAL } else { stream::KIND_WRAP };
304 let wrap_extra: Vec<Tag> = rumor
308 .tags
309 .iter()
310 .filter(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false))
311 .cloned()
312 .collect();
313 Ok(stream::wrap_seal_with_tags(&seal, group, wrap_kind, wrap_at, &wrap_extra)?)
314}
315
316pub async fn seal_chat_rumor_signed<S: crate::signer::VectorSigner + ?Sized>(
320 signer: &S,
321 author: nostr_sdk::prelude::PublicKey,
322 rumor: &UnsignedEvent,
323 group: &GroupKey,
324 wrap_at: Timestamp,
325 ephemeral: bool,
326) -> Result<(Event, Keys), ChatError> {
327 let k = rumor.kind.as_u16();
328 if !is_chat_kind(k) {
329 return Err(ChatError::UnknownKind(k));
330 }
331 let wrap_kind = if ephemeral { stream::KIND_WRAP_EPHEMERAL } else { stream::KIND_WRAP };
332 let wrap_extra: Vec<Tag> = rumor
333 .tags
334 .iter()
335 .filter(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false))
336 .cloned()
337 .collect();
338 Ok(stream::seal_and_wrap_signed(signer, author, rumor, SealForm::Encrypted, group, wrap_kind, wrap_at, &wrap_extra).await?)
339}
340
341#[derive(Debug, Clone, PartialEq, Eq)]
344pub struct ReplyRef {
345 pub id: [u8; 32],
346 pub author: Option<PublicKey>,
347}
348
349#[derive(Debug, Clone)]
352pub enum ChatEvent {
353 Message {
359 opened: OpenedStream,
360 reply_to: Option<ReplyRef>,
361 emoji: Vec<(String, String)>,
362 },
363 Reaction {
366 opened: OpenedStream,
367 target: [u8; 32],
368 target_author: PublicKey,
369 emoji: String,
370 emoji_url: Option<String>,
371 },
372 Delete {
375 opened: OpenedStream,
376 target: [u8; 32],
377 target_kind: Option<u16>,
378 },
379 Edit {
381 opened: OpenedStream,
382 target: [u8; 32],
383 new_content: String,
384 },
385 Webxdc { opened: OpenedStream },
387 Typing { opened: OpenedStream },
389}
390
391impl ChatEvent {
392 pub fn opened(&self) -> &OpenedStream {
394 match self {
395 ChatEvent::Message { opened, .. }
396 | ChatEvent::Reaction { opened, .. }
397 | ChatEvent::Delete { opened, .. }
398 | ChatEvent::Edit { opened, .. }
399 | ChatEvent::Webxdc { opened }
400 | ChatEvent::Typing { opened } => opened,
401 }
402 }
403}
404
405pub fn open_chat_event(
410 wrap: &Event,
411 group: &GroupKey,
412 channel_id: &ChannelId,
413 epoch: Epoch,
414) -> Result<ChatEvent, ChatError> {
415 let opened = stream::open_wrap(wrap, group)?;
416 if opened.seal_form != SealForm::Encrypted {
417 return Err(ChatError::NotEncryptedSealed);
418 }
419 stream::check_channel_binding(&opened.rumor, channel_id, epoch)?;
420 parse_chat_rumor(opened)
421}
422
423pub fn open_chat_event_multi(
435 wrap: &Event,
436 held: &[(Epoch, [u8; 32])],
437 channel_id: &ChannelId,
438) -> Result<(ChatEvent, Epoch), ChatError> {
439 for (epoch, secret) in held {
440 let group = channel_group_key(secret, channel_id, *epoch);
441 if wrap.pubkey == group.pk() {
442 return open_chat_event(wrap, &group, channel_id, *epoch).map(|ev| (ev, *epoch));
443 }
444 }
445 Err(ChatError::NoHeldEpoch)
446}
447
448fn is_chat_kind(k: u16) -> bool {
451 matches!(
452 k,
453 kind::MESSAGE | kind::COMMENT | kind::REACTION | kind::DELETE | kind::EDIT | kind::WEBXDC | kind::TYPING
454 )
455}
456
457fn parse_chat_rumor(opened: OpenedStream) -> Result<ChatEvent, ChatError> {
461 match opened.rumor.kind.as_u16() {
462 kind::MESSAGE => {
463 let reply_to = match unique_tag(&opened.rumor, TAG_QUOTE)? {
464 None => None,
465 Some(s) => {
466 let id = decode_id32(value_of(s, TAG_QUOTE)?, TAG_QUOTE)?;
467 let author = match s.get(3).map(String::as_str).filter(|a| !a.is_empty()) {
470 Some(hex) => Some(PublicKey::from_hex(hex).map_err(|_| ChatError::BadTag(TAG_QUOTE))?),
471 None => None,
472 };
473 Some(ReplyRef { id, author })
474 }
475 };
476 let emoji = collect_emoji(&opened.rumor);
477 Ok(ChatEvent::Message { opened, reply_to, emoji })
478 }
479 kind::COMMENT => {
480 let reply_to = match unique_tag(&opened.rumor, TAG_TARGET)? {
485 None => None, Some(s) => {
487 let id = decode_id32(value_of(s, TAG_TARGET)?, TAG_TARGET)?;
488 let author = match s.get(3).map(String::as_str).filter(|a| !a.is_empty()) {
490 Some(hex) => Some(PublicKey::from_hex(hex).map_err(|_| ChatError::BadTag(TAG_TARGET))?),
491 None => None,
492 };
493 Some(ReplyRef { id, author })
494 }
495 };
496 let emoji = collect_emoji(&opened.rumor);
497 Ok(ChatEvent::Message { opened, reply_to, emoji })
498 }
499 kind::REACTION => {
500 let target = decode_id32(required_tag(&opened.rumor, TAG_TARGET)?, TAG_TARGET)?;
501 let target_author = PublicKey::from_hex(required_tag(&opened.rumor, TAG_TARGET_AUTHOR)?)
502 .map_err(|_| ChatError::BadTag(TAG_TARGET_AUTHOR))?;
503 let emoji_url = collect_emoji(&opened.rumor).into_iter().next().map(|(_, url)| url);
504 let emoji = opened.rumor.content.clone();
505 Ok(ChatEvent::Reaction { opened, target, target_author, emoji, emoji_url })
506 }
507 kind::DELETE => {
508 let target = decode_id32(required_tag(&opened.rumor, TAG_TARGET)?, TAG_TARGET)?;
509 let target_kind = match unique_tag(&opened.rumor, TAG_TARGET_KIND)? {
510 None => None,
511 Some(s) => Some(
512 value_of(s, TAG_TARGET_KIND)?
513 .parse::<u16>()
514 .map_err(|_| ChatError::BadTag(TAG_TARGET_KIND))?,
515 ),
516 };
517 Ok(ChatEvent::Delete { opened, target, target_kind })
518 }
519 kind::EDIT => {
520 let target = decode_id32(required_tag(&opened.rumor, TAG_TARGET)?, TAG_TARGET)?;
521 let new_content = opened.rumor.content.clone();
522 Ok(ChatEvent::Edit { opened, target, new_content })
523 }
524 kind::WEBXDC => Ok(ChatEvent::Webxdc { opened }),
525 kind::TYPING => Ok(ChatEvent::Typing { opened }),
526 k => Err(ChatError::UnknownKind(k)),
527 }
528}
529
530fn emoji_tag(shortcode: &str, url: &str) -> Tag {
533 Tag::custom(TAG_EMOJI, [shortcode.to_string(), url.to_string()])
534}
535
536fn unique_tag<'a>(rumor: &'a UnsignedEvent, name: &'static str) -> Result<Option<&'a [String]>, ChatError> {
539 let mut found: Option<&[String]> = None;
540 for t in rumor.tags.iter() {
541 let s = t.as_slice();
542 if s.first().map(|n| n == name).unwrap_or(false) {
543 if found.is_some() {
544 return Err(ChatError::DuplicateTag(name));
545 }
546 found = Some(s);
547 }
548 }
549 Ok(found)
550}
551
552fn required_tag<'a>(rumor: &'a UnsignedEvent, name: &'static str) -> Result<&'a str, ChatError> {
554 let s = unique_tag(rumor, name)?.ok_or(ChatError::MissingTag(name))?;
555 value_of(s, name)
556}
557
558fn value_of<'a>(slice: &'a [String], name: &'static str) -> Result<&'a str, ChatError> {
559 slice.get(1).map(String::as_str).ok_or(ChatError::BadTag(name))
560}
561
562fn decode_id32(hex: &str, field: &'static str) -> Result<[u8; 32], ChatError> {
563 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
564 return Err(ChatError::BadTag(field));
565 }
566 Ok(crate::simd::hex::hex_to_bytes_32(hex))
567}
568
569fn collect_emoji(rumor: &UnsignedEvent) -> Vec<(String, String)> {
572 rumor
573 .tags
574 .iter()
575 .filter_map(|t| {
576 let s = t.as_slice();
577 (s.len() >= 3 && s[0] == TAG_EMOJI).then(|| (s[1].clone(), s[2].clone()))
578 })
579 .collect()
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585
586 const AT: u64 = 1_686_840_217_417;
587 const WRAP_AT: Timestamp = Timestamp::from_secs(1_700_000_000);
588
589 fn chan() -> ChannelId {
590 ChannelId([0xab; 32])
591 }
592
593 fn secret() -> [u8; 32] {
594 [7u8; 32]
595 }
596
597 fn group() -> GroupKey {
598 chat_group_key(&secret(), &chan(), Epoch(0))
599 }
600
601 fn open(wrap: &Event) -> Result<ChatEvent, ChatError> {
602 open_chat_event(wrap, &group(), &chan(), Epoch(0))
603 }
604
605 fn seal(rumor: &UnsignedEvent, author: &Keys) -> Event {
606 seal_chat_rumor(rumor, &group(), author, WRAP_AT, false).unwrap().0
607 }
608
609 #[test]
610 fn message_round_trip_carries_reply_emoji_and_extra_tags_verbatim() {
611 let author = Keys::generate();
612 let parent = Keys::generate();
613 let parent_id = "aa".repeat(32);
614 let imeta = Tag::custom(
615 "imeta",
616 ["url https://x/f.png".to_string(), "m image/png".to_string()],
617 );
618 let rumor = build_message_rumor(
619 author.public_key(),
620 &chan(),
621 Epoch(0),
622 "welcome :catJAM:",
623 Some((&parent_id, &parent.public_key().to_hex())),
624 &[("catJAM", "https://x/cat.gif")],
625 vec![imeta.clone()],
626 AT,
627 );
628 let wrap = seal(&rumor, &author);
629
630 let ChatEvent::Message { opened, reply_to, emoji } = open(&wrap).unwrap() else {
631 panic!("expected a Message");
632 };
633 assert_eq!(opened.author, author.public_key());
634 assert_eq!(opened.rumor.content, "welcome :catJAM:");
635 assert_eq!(opened.at_ms, AT);
636 let reply = reply_to.expect("reply parses back");
637 assert_eq!(reply.id, [0xaa; 32]);
638 assert_eq!(reply.author, Some(parent.public_key()));
639 assert_eq!(emoji, vec![("catJAM".to_string(), "https://x/cat.gif".to_string())]);
640 assert!(opened.rumor.tags.iter().any(|t| t.as_slice() == imeta.as_slice()));
642 }
643
644 #[test]
645 fn self_destruct_expiration_mirrors_onto_the_wrap_and_round_trips_into_the_message() {
646 const EXP: u64 = 1_700_000_500;
651 let author = Keys::generate();
652 let me = author.public_key();
653
654 let rumor = build_message_rumor(
656 me,
657 &chan(),
658 Epoch(0),
659 "poof",
660 None,
661 &[],
662 vec![Tag::expiration(Timestamp::from_secs(EXP))],
663 AT,
664 );
665 assert_eq!(message_expiration(&rumor), Some(EXP), "inner rumor carries the expiry");
666
667 let wrap = seal(&rumor, &author);
668 let mirrored = wrap.tags.iter().any(|t| {
670 let s = t.as_slice();
671 s.len() >= 2 && s[0] == "expiration" && s[1] == EXP.to_string()
672 });
673 assert!(mirrored, "the outer wrap mirrors the NIP-40 expiration for relays");
674
675 let ChatEvent::Message { opened, reply_to, emoji } = open(&wrap).unwrap() else {
677 panic!("expected a Message");
678 };
679 let msg = crate::community::v2::inbound::chat_message_to_message(&opened, &reply_to, &emoji, &me);
680 assert_eq!(msg.expiration, Some(EXP), "the receiver's Message carries the expiry");
681
682 let plain = build_message_rumor(me, &chan(), Epoch(0), "forever", None, &[], vec![], AT);
684 assert_eq!(message_expiration(&plain), None);
685 let plain_wrap = seal(&plain, &author);
686 assert!(!plain_wrap
687 .tags
688 .iter()
689 .any(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false)));
690 let ChatEvent::Message { opened, reply_to, emoji } = open(&plain_wrap).unwrap() else {
691 panic!("expected a Message");
692 };
693 let plain_msg = crate::community::v2::inbound::chat_message_to_message(&opened, &reply_to, &emoji, &me);
694 assert_eq!(plain_msg.expiration, None);
695 }
696
697 #[test]
698 fn message_without_q_has_no_reply() {
699 let author = Keys::generate();
700 let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), "hi", None, &[], vec![], AT);
701 let ChatEvent::Message { reply_to, emoji, .. } = open(&seal(&rumor, &author)).unwrap() else {
702 panic!("expected a Message");
703 };
704 assert_eq!(reply_to, None);
705 assert!(emoji.is_empty());
706 }
707
708 #[test]
709 fn a_threaded_reply_round_trips_as_a_message_with_its_parent_as_reply_context() {
710 let author = Keys::generate();
713 let root_author = Keys::generate();
714 let root_id = "cd".repeat(32);
715 let rumor = build_comment_rumor(
716 author.public_key(),
717 &chan(),
718 Epoch(0),
719 "replying in the thread!",
720 &root_id,
721 kind::MESSAGE,
722 &root_author.public_key().to_hex(),
723 None, &[],
725 AT,
726 );
727 assert!(rumor.tags.iter().any(|t| t.as_slice() == ["K", "9"]));
729 assert!(rumor.tags.iter().any(|t| t.as_slice()[0] == "E" && t.as_slice()[1] == root_id));
730 assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "9"]));
731
732 let ChatEvent::Message { opened, reply_to, .. } = open(&seal(&rumor, &author)).unwrap() else {
733 panic!("a threaded reply parses as a Message");
734 };
735 assert_eq!(opened.rumor.kind.as_u16(), kind::COMMENT, "the wire kind is preserved on the rumor");
736 let reply = reply_to.expect("the immediate parent is the reply context");
737 assert_eq!(reply.id, [0xcd; 32]);
738 assert_eq!(reply.author, Some(root_author.public_key()));
739 }
740
741 #[test]
742 fn an_armada_shaped_threaded_reply_parses_verbatim() {
743 let author = Keys::generate();
747 let root_author = Keys::generate();
748 let parent_author = Keys::generate();
749 let root_id = "ef".repeat(32);
750 let parent_id = "12".repeat(32);
751 let tags = vec![
752 Tag::custom("channel", [crate::simd::hex::bytes_to_hex_32(&chan().0)]),
753 Tag::custom("epoch", ["0".to_string()]),
754 Tag::custom("K", ["9".to_string()]),
755 Tag::custom(
756 "E",
757 [root_id.clone(), String::new(), root_author.public_key().to_hex()],
758 ),
759 Tag::custom("P", [root_author.public_key().to_hex()]),
760 Tag::custom("k", ["1111".to_string()]),
761 Tag::custom("e", [parent_id.clone(), String::new(), parent_author.public_key().to_hex()]),
762 Tag::custom("p", [parent_author.public_key().to_hex()]),
763 ];
764 let rumor = stream::build_rumor_ms(kind::COMMENT, author.public_key(), "nested reply", tags, AT);
765 let ChatEvent::Message { reply_to, .. } = open(&seal(&rumor, &author)).unwrap() else {
766 panic!("expected a Message");
767 };
768 let reply = reply_to.expect("parent parses");
771 assert_eq!(reply.id, [0x12; 32]);
772 assert_eq!(reply.author, Some(parent_author.public_key()));
773 }
774
775 #[test]
776 fn a_nested_comment_inherits_its_root_tags_verbatim() {
777 let author = Keys::generate();
778 let root_id = "ab".repeat(32);
779 let root_author_hex = Keys::generate().public_key().to_hex();
780 let parent_id = "cd".repeat(32);
781 let parent_author_hex = Keys::generate().public_key().to_hex();
782 let rumor = build_comment_rumor(
783 author.public_key(),
784 &chan(),
785 Epoch(0),
786 "deep",
787 &parent_id,
788 kind::COMMENT, &parent_author_hex,
790 Some((&root_id, kind::MESSAGE, &root_author_hex)),
791 &[],
792 AT,
793 );
794 assert!(rumor.tags.iter().any(|t| t.as_slice()[0] == "E" && t.as_slice()[1] == root_id));
797 assert!(rumor.tags.iter().any(|t| t.as_slice() == ["K", "9"]));
798 assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "1111"]));
799 assert!(rumor.tags.iter().any(|t| t.as_slice()[0] == "e" && t.as_slice()[1] == parent_id));
800 }
801
802 #[test]
803 fn a_comment_with_duplicate_parent_tags_is_rejected() {
804 let author = Keys::generate();
807 let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
808 for id in ["ab", "ff"] {
809 tags.push(Tag::custom("e", [
810 id.repeat(32),
811 String::new(),
812 Keys::generate().public_key().to_hex(),
813 ]));
814 }
815 let rumor = stream::build_rumor_ms(kind::COMMENT, author.public_key(), "ambiguous", tags, AT);
816 let got = open(&seal(&rumor, &author));
817 assert!(matches!(&got, Err(ChatError::DuplicateTag("e"))), "got: {got:?}");
818 }
819
820 #[test]
821 fn a_reaction_to_a_threaded_reply_carries_k_1111() {
822 let author = Keys::generate();
823 let rumor = build_reaction_rumor(
824 author.public_key(),
825 &chan(),
826 Epoch(0),
827 &"bc".repeat(32),
828 &Keys::generate().public_key().to_hex(),
829 kind::COMMENT,
830 "🔥",
831 None,
832 AT,
833 );
834 assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "1111"]), "the k tag names the target's kind");
835 assert!(matches!(open(&seal(&rumor, &author)).unwrap(), ChatEvent::Reaction { .. }));
836 }
837
838 #[test]
839 fn reaction_round_trip_and_nip25_shape() {
840 let author = Keys::generate();
841 let target_author = Keys::generate();
842 let target_id = "bc".repeat(32);
843 let rumor = build_reaction_rumor(
844 author.public_key(),
845 &chan(),
846 Epoch(0),
847 &target_id,
848 &target_author.public_key().to_hex(),
849 kind::MESSAGE,
850 "🔥",
851 None,
852 AT,
853 );
854 assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "9"]));
856
857 let ChatEvent::Reaction { opened, target, target_author: ta, emoji, emoji_url } =
858 open(&seal(&rumor, &author)).unwrap()
859 else {
860 panic!("expected a Reaction");
861 };
862 assert_eq!(opened.author, author.public_key());
863 assert_eq!(target, [0xbc; 32]);
864 assert_eq!(ta, target_author.public_key());
865 assert_eq!(emoji, "🔥");
866 assert_eq!(emoji_url, None);
867 assert_eq!(opened.at_ms, AT);
868 }
869
870 #[test]
871 fn reaction_custom_emoji_carries_the_nip30_url() {
872 let author = Keys::generate();
873 let rumor = build_reaction_rumor(
874 author.public_key(),
875 &chan(),
876 Epoch(0),
877 &"bc".repeat(32),
878 &Keys::generate().public_key().to_hex(),
879 kind::MESSAGE,
880 ":catJAM:",
881 Some(("catJAM", "https://x/cat.gif")),
882 AT,
883 );
884 let ChatEvent::Reaction { emoji, emoji_url, .. } = open(&seal(&rumor, &author)).unwrap() else {
885 panic!("expected a Reaction");
886 };
887 assert_eq!(emoji, ":catJAM:");
888 assert_eq!(emoji_url, Some("https://x/cat.gif".to_string()));
889 }
890
891 #[test]
892 fn delete_round_trip_and_optional_target_kind() {
893 let author = Keys::generate();
894 let rumor = build_delete_rumor(author.public_key(), &chan(), Epoch(0), &"cd".repeat(32), kind::MESSAGE, AT, None);
895 let ChatEvent::Delete { target, target_kind, .. } = open(&seal(&rumor, &author)).unwrap() else {
896 panic!("expected a Delete");
897 };
898 assert_eq!(target, [0xcd; 32]);
899 assert_eq!(target_kind, Some(kind::MESSAGE));
900
901 let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
903 tags.push(Tag::custom("e", ["cd".repeat(32)]));
904 let bare = stream::build_rumor_ms(kind::DELETE, author.public_key(), "", tags, AT);
905 let ChatEvent::Delete { target_kind, .. } = open(&seal(&bare, &author)).unwrap() else {
906 panic!("expected a Delete");
907 };
908 assert_eq!(target_kind, None);
909 }
910
911 #[test]
912 fn edit_round_trip_replaces_content() {
913 let author = Keys::generate();
914 let rumor = build_edit_rumor(author.public_key(), &chan(), Epoch(0), &"de".repeat(32), "fixed the typo", AT);
915 let ChatEvent::Edit { opened, target, new_content } = open(&seal(&rumor, &author)).unwrap() else {
916 panic!("expected an Edit");
917 };
918 assert_eq!(opened.author, author.public_key());
919 assert_eq!(target, [0xde; 32]);
920 assert_eq!(new_content, "fixed the typo");
921 }
922
923 #[test]
924 fn webxdc_round_trip_is_opaque() {
925 let author = Keys::generate();
926 let app_tag = Tag::custom("xdc", ["state-update".to_string()]);
927 let rumor = build_webxdc_rumor(
928 author.public_key(),
929 &chan(),
930 Epoch(0),
931 "{\"move\":\"e4\"}",
932 vec![app_tag.clone()],
933 AT,
934 );
935 let ChatEvent::Webxdc { opened } = open(&seal(&rumor, &author)).unwrap() else {
936 panic!("expected a Webxdc");
937 };
938 assert_eq!(opened.rumor.content, "{\"move\":\"e4\"}");
939 assert!(opened.rumor.tags.iter().any(|t| t.as_slice() == app_tag.as_slice()));
940 }
941
942 #[test]
943 fn typing_rides_ephemeral_and_wrap_tier_is_not_content_authority() {
944 let author = Keys::generate();
945 let typing = build_typing_rumor(author.public_key(), &chan(), Epoch(0), AT);
946 let (wrap, _) = seal_chat_rumor(&typing, &group(), &author, WRAP_AT, true).unwrap();
947 assert_eq!(wrap.kind.as_u16(), stream::KIND_WRAP_EPHEMERAL);
948 let ChatEvent::Typing { opened } = open(&wrap).unwrap() else {
949 panic!("expected a Typing");
950 };
951 assert_eq!(opened.author, author.public_key());
952 assert_eq!(opened.rumor.content, "");
953
954 let msg = build_message_rumor(author.public_key(), &chan(), Epoch(0), "live", None, &[], vec![], AT);
957 let (wrap, _) = seal_chat_rumor(&msg, &group(), &author, WRAP_AT, true).unwrap();
958 assert!(matches!(open(&wrap), Ok(ChatEvent::Message { .. })));
959 }
960
961 #[test]
962 fn wrong_channel_and_wrong_epoch_are_rejected() {
963 let author = Keys::generate();
964 let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), "x", None, &[], vec![], AT);
965 let wrap = seal(&rumor, &author);
966 assert!(matches!(
968 open_chat_event(&wrap, &group(), &ChannelId([0xcd; 32]), Epoch(0)),
969 Err(ChatError::Stream(StreamError::ChannelMismatch))
970 ));
971 let stale = build_message_rumor(author.public_key(), &chan(), Epoch(1), "x", None, &[], vec![], AT);
973 let wrap = seal(&stale, &author);
974 assert!(matches!(open(&wrap), Err(ChatError::Stream(StreamError::EpochMismatch))));
975 }
976
977 #[test]
978 fn reaction_bound_to_channel_a_under_channel_b_key_is_rejected() {
979 let author = Keys::generate();
983 let chan_a = ChannelId([0xaa; 32]);
984 let chan_b = ChannelId([0xbb; 32]);
985 let group_b = chat_group_key(&secret(), &chan_b, Epoch(0));
986 let rumor = build_reaction_rumor(
987 author.public_key(),
988 &chan_a,
989 Epoch(0),
990 &"bc".repeat(32),
991 &Keys::generate().public_key().to_hex(),
992 kind::MESSAGE,
993 "🔥",
994 None,
995 AT,
996 );
997 let (wrap, _) = seal_chat_rumor(&rumor, &group_b, &author, WRAP_AT, false).unwrap();
998 assert!(matches!(
999 open_chat_event(&wrap, &group_b, &chan_b, Epoch(0)),
1000 Err(ChatError::Stream(StreamError::ChannelMismatch))
1001 ));
1002 }
1003
1004 #[test]
1005 fn multi_epoch_opens_each_wrap_under_its_own_epoch() {
1006 let author = Keys::generate();
1007 let key0 = [1u8; 32];
1008 let key1 = [2u8; 32];
1009 let held = [(Epoch(0), key0), (Epoch(1), key1)];
1010
1011 let m0 = build_message_rumor(author.public_key(), &chan(), Epoch(0), "before the rekey", None, &[], vec![], AT);
1012 let g0 = chat_group_key(&key0, &chan(), Epoch(0));
1013 let (w0, _) = seal_chat_rumor(&m0, &g0, &author, WRAP_AT, false).unwrap();
1014
1015 let m1 = build_message_rumor(author.public_key(), &chan(), Epoch(1), "after the rekey", None, &[], vec![], AT + 1);
1016 let g1 = chat_group_key(&key1, &chan(), Epoch(1));
1017 let (w1, _) = seal_chat_rumor(&m1, &g1, &author, WRAP_AT, false).unwrap();
1018
1019 let (ev0, e0) = open_chat_event_multi(&w0, &held, &chan()).unwrap();
1020 assert_eq!(e0, Epoch(0));
1021 assert_eq!(ev0.opened().rumor.content, "before the rekey");
1022 let (ev1, e1) = open_chat_event_multi(&w1, &held, &chan()).unwrap();
1023 assert_eq!(e1, Epoch(1));
1024 assert_eq!(ev1.opened().rumor.content, "after the rekey");
1025 }
1026
1027 #[test]
1028 fn multi_epoch_unheld_wrap_is_not_ours() {
1029 let author = Keys::generate();
1030 let held = [(Epoch(0), [1u8; 32]), (Epoch(1), [2u8; 32])];
1031 let m2 = build_message_rumor(author.public_key(), &chan(), Epoch(2), "future", None, &[], vec![], AT);
1032 let g2 = chat_group_key(&[3u8; 32], &chan(), Epoch(2));
1033 let (w2, _) = seal_chat_rumor(&m2, &g2, &author, WRAP_AT, false).unwrap();
1034 assert!(matches!(open_chat_event_multi(&w2, &held, &chan()), Err(ChatError::NoHeldEpoch)));
1035 }
1036
1037 #[test]
1038 fn multi_epoch_cross_epoch_splice_is_rejected() {
1039 let author = Keys::generate();
1043 let key0 = [1u8; 32];
1044 let key1 = [2u8; 32];
1045 let held = [(Epoch(0), key0), (Epoch(1), key1)];
1046 let stale = build_message_rumor(author.public_key(), &chan(), Epoch(0), "replay", None, &[], vec![], AT);
1047 let g1 = chat_group_key(&key1, &chan(), Epoch(1));
1048 let (wrap, _) = seal_chat_rumor(&stale, &g1, &author, WRAP_AT, false).unwrap();
1049 assert!(matches!(
1050 open_chat_event_multi(&wrap, &held, &chan()),
1051 Err(ChatError::Stream(StreamError::EpochMismatch))
1052 ));
1053 }
1054
1055 #[test]
1056 fn duplicate_e_tag_on_a_reaction_is_rejected() {
1057 let author = Keys::generate();
1058 let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
1059 tags.push(Tag::custom("e", ["aa".repeat(32)]));
1060 tags.push(Tag::custom("e", ["bb".repeat(32)]));
1061 tags.push(Tag::custom("p", [Keys::generate().public_key().to_hex()]));
1062 let rumor = stream::build_rumor_ms(kind::REACTION, author.public_key(), "+", tags, AT);
1063 assert!(matches!(
1064 open(&seal(&rumor, &author)),
1065 Err(ChatError::DuplicateTag(TAG_TARGET))
1066 ));
1067 }
1068
1069 #[test]
1070 fn unknown_rumor_kind_is_rejected_on_both_sides() {
1071 let author = Keys::generate();
1072 let tags = stream::channel_binding_tags(&chan(), Epoch(0));
1074 let rumor = stream::build_rumor_ms(3300, author.public_key(), "v1 ghost", tags, AT);
1075 assert!(matches!(
1076 seal_chat_rumor(&rumor, &group(), &author, WRAP_AT, false),
1077 Err(ChatError::UnknownKind(3300))
1078 ));
1079 let seal = stream::build_seal(&rumor, SealForm::Encrypted, &group(), &author).unwrap();
1081 let (wrap, _) = stream::wrap_seal(&seal, &group(), stream::KIND_WRAP, WRAP_AT).unwrap();
1082 assert!(matches!(open(&wrap), Err(ChatError::UnknownKind(3300))));
1083 }
1084
1085 #[test]
1086 fn plaintext_sealed_chat_event_is_rejected() {
1087 let author = Keys::generate();
1088 let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), "leaky", None, &[], vec![], AT);
1089 let seal = stream::build_seal(&rumor, SealForm::Plaintext, &group(), &author).unwrap();
1090 let (wrap, _) = stream::wrap_seal(&seal, &group(), stream::KIND_WRAP, WRAP_AT).unwrap();
1091 assert!(matches!(open(&wrap), Err(ChatError::NotEncryptedSealed)));
1092 }
1093
1094 #[test]
1095 fn malformed_targets_are_errors_not_panics() {
1096 let author = Keys::generate();
1097 let rumor = build_reaction_rumor(
1099 author.public_key(),
1100 &chan(),
1101 Epoch(0),
1102 &"zz".repeat(32),
1103 &Keys::generate().public_key().to_hex(),
1104 kind::MESSAGE,
1105 "+",
1106 None,
1107 AT,
1108 );
1109 assert!(matches!(open(&seal(&rumor, &author)), Err(ChatError::BadTag(TAG_TARGET))));
1110 let rumor = build_message_rumor(
1112 author.public_key(),
1113 &chan(),
1114 Epoch(0),
1115 "x",
1116 Some(("abcd", &Keys::generate().public_key().to_hex())),
1117 &[],
1118 vec![],
1119 AT,
1120 );
1121 assert!(matches!(open(&seal(&rumor, &author)), Err(ChatError::BadTag(TAG_QUOTE))));
1122 let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
1124 tags.push(Tag::custom("e", ["cd".repeat(32)]));
1125 tags.push(Tag::custom(
1126 "k",
1127 ["nine".to_string()],
1128 ));
1129 let rumor = stream::build_rumor_ms(kind::DELETE, author.public_key(), "", tags, AT);
1130 assert!(matches!(open(&seal(&rumor, &author)), Err(ChatError::BadTag(TAG_TARGET_KIND))));
1131 let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
1133 tags.push(Tag::custom("e", ["cd".repeat(32)]));
1134 let rumor = stream::build_rumor_ms(kind::REACTION, author.public_key(), "+", tags, AT);
1135 assert!(matches!(
1136 open(&seal(&rumor, &author)),
1137 Err(ChatError::MissingTag(TAG_TARGET_AUTHOR))
1138 ));
1139 }
1140}