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