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
132#[allow(clippy::too_many_arguments)]
139pub fn build_comment_rumor(
140 author: PublicKey,
141 channel_id: &ChannelId,
142 epoch: Epoch,
143 content: &str,
144 parent_id_hex: &str,
145 parent_kind: u16,
146 parent_author_hex: &str,
147 parent_root: Option<(&str, u16, &str)>,
148 emoji: &[(&str, &str)],
149 at_ms: u64,
150) -> UnsignedEvent {
151 let mut tags = stream::channel_binding_tags(channel_id, epoch);
152 let (root_id, root_kind, root_author) = parent_root.unwrap_or((parent_id_hex, parent_kind, parent_author_hex));
153 tags.push(Tag::custom(TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::K)), [root_kind.to_string()]));
154 tags.push(Tag::custom(
155 TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::E)),
156 [root_id.to_string(), String::new(), root_author.to_string()],
157 ));
158 tags.push(Tag::custom(TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::P)), [root_author.to_string()]));
159 tags.push(Tag::custom(TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::K)), [parent_kind.to_string()]));
160 tags.push(Tag::custom(
161 TagKind::e(),
162 [parent_id_hex.to_string(), String::new(), parent_author_hex.to_string()],
163 ));
164 tags.push(Tag::custom(TagKind::p(), [parent_author_hex.to_string()]));
165 for (shortcode, url) in emoji {
166 tags.push(emoji_tag(shortcode, url));
167 }
168 stream::build_rumor_ms(kind::COMMENT, author, content, tags, at_ms)
169}
170
171#[allow(clippy::too_many_arguments)]
177pub fn build_reaction_rumor(
178 author: PublicKey,
179 channel_id: &ChannelId,
180 epoch: Epoch,
181 target_rumor_id_hex: &str,
182 target_author_hex: &str,
183 target_kind: u16,
184 emoji_content: &str,
185 emoji: Option<(&str, &str)>,
186 at_ms: u64,
187) -> UnsignedEvent {
188 let mut tags = stream::channel_binding_tags(channel_id, epoch);
189 tags.push(Tag::custom(TagKind::e(), [target_rumor_id_hex.to_string()]));
190 tags.push(Tag::custom(TagKind::p(), [target_author_hex.to_string()]));
191 tags.push(Tag::custom(
192 TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::K)),
193 [target_kind.to_string()],
194 ));
195 if let Some((shortcode, url)) = emoji {
196 tags.push(emoji_tag(shortcode, url));
197 }
198 stream::build_rumor_ms(kind::REACTION, author, emoji_content, tags, at_ms)
199}
200
201pub fn build_delete_rumor(
205 author: PublicKey,
206 channel_id: &ChannelId,
207 epoch: Epoch,
208 target_rumor_id_hex: &str,
209 target_kind: u16,
210 at_ms: u64,
211) -> UnsignedEvent {
212 let mut tags = stream::channel_binding_tags(channel_id, epoch);
213 tags.push(Tag::custom(TagKind::e(), [target_rumor_id_hex.to_string()]));
214 tags.push(Tag::custom(
215 TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::K)),
216 [target_kind.to_string()],
217 ));
218 stream::build_rumor_ms(kind::DELETE, author, "", tags, at_ms)
219}
220
221pub fn build_edit_rumor(
225 author: PublicKey,
226 channel_id: &ChannelId,
227 epoch: Epoch,
228 target_rumor_id_hex: &str,
229 new_content: &str,
230 at_ms: u64,
231) -> UnsignedEvent {
232 let mut tags = stream::channel_binding_tags(channel_id, epoch);
233 tags.push(Tag::custom(TagKind::e(), [target_rumor_id_hex.to_string()]));
234 stream::build_rumor_ms(kind::EDIT, author, new_content, tags, at_ms)
235}
236
237pub fn build_webxdc_rumor(
240 author: PublicKey,
241 channel_id: &ChannelId,
242 epoch: Epoch,
243 content: &str,
244 extra_tags: Vec<Tag>,
245 at_ms: u64,
246) -> UnsignedEvent {
247 let mut tags = stream::channel_binding_tags(channel_id, epoch);
248 tags.extend(extra_tags);
249 stream::build_rumor_ms(kind::WEBXDC, author, content, tags, at_ms)
250}
251
252pub fn build_typing_rumor(author: PublicKey, channel_id: &ChannelId, epoch: Epoch, at_ms: u64) -> UnsignedEvent {
255 let tags = stream::channel_binding_tags(channel_id, epoch);
256 stream::build_rumor_ms(kind::TYPING, author, "", tags, at_ms)
257}
258
259pub fn seal_chat_rumor(
269 rumor: &UnsignedEvent,
270 group: &GroupKey,
271 author_keys: &Keys,
272 wrap_at: Timestamp,
273 ephemeral: bool,
274) -> Result<(Event, Keys), ChatError> {
275 let k = rumor.kind.as_u16();
276 if !is_chat_kind(k) {
277 return Err(ChatError::UnknownKind(k));
278 }
279 let seal = stream::build_seal(rumor, SealForm::Encrypted, group, author_keys)?;
280 let wrap_kind = if ephemeral { stream::KIND_WRAP_EPHEMERAL } else { stream::KIND_WRAP };
281 Ok(stream::wrap_seal(&seal, group, wrap_kind, wrap_at)?)
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct ReplyRef {
288 pub id: [u8; 32],
289 pub author: Option<PublicKey>,
290}
291
292#[derive(Debug, Clone)]
295pub enum ChatEvent {
296 Message {
302 opened: OpenedStream,
303 reply_to: Option<ReplyRef>,
304 emoji: Vec<(String, String)>,
305 },
306 Reaction {
309 opened: OpenedStream,
310 target: [u8; 32],
311 target_author: PublicKey,
312 emoji: String,
313 emoji_url: Option<String>,
314 },
315 Delete {
318 opened: OpenedStream,
319 target: [u8; 32],
320 target_kind: Option<u16>,
321 },
322 Edit {
324 opened: OpenedStream,
325 target: [u8; 32],
326 new_content: String,
327 },
328 Webxdc { opened: OpenedStream },
330 Typing { opened: OpenedStream },
332}
333
334impl ChatEvent {
335 pub fn opened(&self) -> &OpenedStream {
337 match self {
338 ChatEvent::Message { opened, .. }
339 | ChatEvent::Reaction { opened, .. }
340 | ChatEvent::Delete { opened, .. }
341 | ChatEvent::Edit { opened, .. }
342 | ChatEvent::Webxdc { opened }
343 | ChatEvent::Typing { opened } => opened,
344 }
345 }
346}
347
348pub fn open_chat_event(
353 wrap: &Event,
354 group: &GroupKey,
355 channel_id: &ChannelId,
356 epoch: Epoch,
357) -> Result<ChatEvent, ChatError> {
358 let opened = stream::open_wrap(wrap, group)?;
359 if opened.seal_form != SealForm::Encrypted {
360 return Err(ChatError::NotEncryptedSealed);
361 }
362 stream::check_channel_binding(&opened.rumor, channel_id, epoch)?;
363 parse_chat_rumor(opened)
364}
365
366pub fn open_chat_event_multi(
378 wrap: &Event,
379 held: &[(Epoch, [u8; 32])],
380 channel_id: &ChannelId,
381) -> Result<(ChatEvent, Epoch), ChatError> {
382 for (epoch, secret) in held {
383 let group = channel_group_key(secret, channel_id, *epoch);
384 if wrap.pubkey == group.pk() {
385 return open_chat_event(wrap, &group, channel_id, *epoch).map(|ev| (ev, *epoch));
386 }
387 }
388 Err(ChatError::NoHeldEpoch)
389}
390
391fn is_chat_kind(k: u16) -> bool {
394 matches!(
395 k,
396 kind::MESSAGE | kind::COMMENT | kind::REACTION | kind::DELETE | kind::EDIT | kind::WEBXDC | kind::TYPING
397 )
398}
399
400fn parse_chat_rumor(opened: OpenedStream) -> Result<ChatEvent, ChatError> {
404 match opened.rumor.kind.as_u16() {
405 kind::MESSAGE => {
406 let reply_to = match unique_tag(&opened.rumor, TAG_QUOTE)? {
407 None => None,
408 Some(s) => {
409 let id = decode_id32(value_of(s, TAG_QUOTE)?, TAG_QUOTE)?;
410 let author = match s.get(3).map(String::as_str).filter(|a| !a.is_empty()) {
413 Some(hex) => Some(PublicKey::from_hex(hex).map_err(|_| ChatError::BadTag(TAG_QUOTE))?),
414 None => None,
415 };
416 Some(ReplyRef { id, author })
417 }
418 };
419 let emoji = collect_emoji(&opened.rumor);
420 Ok(ChatEvent::Message { opened, reply_to, emoji })
421 }
422 kind::COMMENT => {
423 let reply_to = match unique_tag(&opened.rumor, TAG_TARGET)? {
428 None => None, Some(s) => {
430 let id = decode_id32(value_of(s, TAG_TARGET)?, TAG_TARGET)?;
431 let author = match s.get(3).map(String::as_str).filter(|a| !a.is_empty()) {
433 Some(hex) => Some(PublicKey::from_hex(hex).map_err(|_| ChatError::BadTag(TAG_TARGET))?),
434 None => None,
435 };
436 Some(ReplyRef { id, author })
437 }
438 };
439 let emoji = collect_emoji(&opened.rumor);
440 Ok(ChatEvent::Message { opened, reply_to, emoji })
441 }
442 kind::REACTION => {
443 let target = decode_id32(required_tag(&opened.rumor, TAG_TARGET)?, TAG_TARGET)?;
444 let target_author = PublicKey::from_hex(required_tag(&opened.rumor, TAG_TARGET_AUTHOR)?)
445 .map_err(|_| ChatError::BadTag(TAG_TARGET_AUTHOR))?;
446 let emoji_url = collect_emoji(&opened.rumor).into_iter().next().map(|(_, url)| url);
447 let emoji = opened.rumor.content.clone();
448 Ok(ChatEvent::Reaction { opened, target, target_author, emoji, emoji_url })
449 }
450 kind::DELETE => {
451 let target = decode_id32(required_tag(&opened.rumor, TAG_TARGET)?, TAG_TARGET)?;
452 let target_kind = match unique_tag(&opened.rumor, TAG_TARGET_KIND)? {
453 None => None,
454 Some(s) => Some(
455 value_of(s, TAG_TARGET_KIND)?
456 .parse::<u16>()
457 .map_err(|_| ChatError::BadTag(TAG_TARGET_KIND))?,
458 ),
459 };
460 Ok(ChatEvent::Delete { opened, target, target_kind })
461 }
462 kind::EDIT => {
463 let target = decode_id32(required_tag(&opened.rumor, TAG_TARGET)?, TAG_TARGET)?;
464 let new_content = opened.rumor.content.clone();
465 Ok(ChatEvent::Edit { opened, target, new_content })
466 }
467 kind::WEBXDC => Ok(ChatEvent::Webxdc { opened }),
468 kind::TYPING => Ok(ChatEvent::Typing { opened }),
469 k => Err(ChatError::UnknownKind(k)),
470 }
471}
472
473fn emoji_tag(shortcode: &str, url: &str) -> Tag {
476 Tag::custom(TagKind::Custom(TAG_EMOJI.into()), [shortcode.to_string(), url.to_string()])
477}
478
479fn unique_tag<'a>(rumor: &'a UnsignedEvent, name: &'static str) -> Result<Option<&'a [String]>, ChatError> {
482 let mut found: Option<&[String]> = None;
483 for t in rumor.tags.iter() {
484 let s = t.as_slice();
485 if s.first().map(|n| n == name).unwrap_or(false) {
486 if found.is_some() {
487 return Err(ChatError::DuplicateTag(name));
488 }
489 found = Some(s);
490 }
491 }
492 Ok(found)
493}
494
495fn required_tag<'a>(rumor: &'a UnsignedEvent, name: &'static str) -> Result<&'a str, ChatError> {
497 let s = unique_tag(rumor, name)?.ok_or(ChatError::MissingTag(name))?;
498 value_of(s, name)
499}
500
501fn value_of<'a>(slice: &'a [String], name: &'static str) -> Result<&'a str, ChatError> {
502 slice.get(1).map(String::as_str).ok_or(ChatError::BadTag(name))
503}
504
505fn decode_id32(hex: &str, field: &'static str) -> Result<[u8; 32], ChatError> {
506 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
507 return Err(ChatError::BadTag(field));
508 }
509 Ok(crate::simd::hex::hex_to_bytes_32(hex))
510}
511
512fn collect_emoji(rumor: &UnsignedEvent) -> Vec<(String, String)> {
515 rumor
516 .tags
517 .iter()
518 .filter_map(|t| {
519 let s = t.as_slice();
520 (s.len() >= 3 && s[0] == TAG_EMOJI).then(|| (s[1].clone(), s[2].clone()))
521 })
522 .collect()
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528
529 const AT: u64 = 1_686_840_217_417;
530 const WRAP_AT: Timestamp = Timestamp::from_secs(1_700_000_000);
531
532 fn chan() -> ChannelId {
533 ChannelId([0xab; 32])
534 }
535
536 fn secret() -> [u8; 32] {
537 [7u8; 32]
538 }
539
540 fn group() -> GroupKey {
541 chat_group_key(&secret(), &chan(), Epoch(0))
542 }
543
544 fn open(wrap: &Event) -> Result<ChatEvent, ChatError> {
545 open_chat_event(wrap, &group(), &chan(), Epoch(0))
546 }
547
548 fn seal(rumor: &UnsignedEvent, author: &Keys) -> Event {
549 seal_chat_rumor(rumor, &group(), author, WRAP_AT, false).unwrap().0
550 }
551
552 #[test]
553 fn message_round_trip_carries_reply_emoji_and_extra_tags_verbatim() {
554 let author = Keys::generate();
555 let parent = Keys::generate();
556 let parent_id = "aa".repeat(32);
557 let imeta = Tag::custom(
558 TagKind::Custom("imeta".into()),
559 ["url https://x/f.png".to_string(), "m image/png".to_string()],
560 );
561 let rumor = build_message_rumor(
562 author.public_key(),
563 &chan(),
564 Epoch(0),
565 "welcome :catJAM:",
566 Some((&parent_id, &parent.public_key().to_hex())),
567 &[("catJAM", "https://x/cat.gif")],
568 vec![imeta.clone()],
569 AT,
570 );
571 let wrap = seal(&rumor, &author);
572
573 let ChatEvent::Message { opened, reply_to, emoji } = open(&wrap).unwrap() else {
574 panic!("expected a Message");
575 };
576 assert_eq!(opened.author, author.public_key());
577 assert_eq!(opened.rumor.content, "welcome :catJAM:");
578 assert_eq!(opened.at_ms, AT);
579 let reply = reply_to.expect("reply parses back");
580 assert_eq!(reply.id, [0xaa; 32]);
581 assert_eq!(reply.author, Some(parent.public_key()));
582 assert_eq!(emoji, vec![("catJAM".to_string(), "https://x/cat.gif".to_string())]);
583 assert!(opened.rumor.tags.iter().any(|t| t.as_slice() == imeta.as_slice()));
585 }
586
587 #[test]
588 fn message_without_q_has_no_reply() {
589 let author = Keys::generate();
590 let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), "hi", None, &[], vec![], AT);
591 let ChatEvent::Message { reply_to, emoji, .. } = open(&seal(&rumor, &author)).unwrap() else {
592 panic!("expected a Message");
593 };
594 assert_eq!(reply_to, None);
595 assert!(emoji.is_empty());
596 }
597
598 #[test]
599 fn a_threaded_reply_round_trips_as_a_message_with_its_parent_as_reply_context() {
600 let author = Keys::generate();
603 let root_author = Keys::generate();
604 let root_id = "cd".repeat(32);
605 let rumor = build_comment_rumor(
606 author.public_key(),
607 &chan(),
608 Epoch(0),
609 "replying in the thread!",
610 &root_id,
611 kind::MESSAGE,
612 &root_author.public_key().to_hex(),
613 None, &[],
615 AT,
616 );
617 assert!(rumor.tags.iter().any(|t| t.as_slice() == ["K", "9"]));
619 assert!(rumor.tags.iter().any(|t| t.as_slice()[0] == "E" && t.as_slice()[1] == root_id));
620 assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "9"]));
621
622 let ChatEvent::Message { opened, reply_to, .. } = open(&seal(&rumor, &author)).unwrap() else {
623 panic!("a threaded reply parses as a Message");
624 };
625 assert_eq!(opened.rumor.kind.as_u16(), kind::COMMENT, "the wire kind is preserved on the rumor");
626 let reply = reply_to.expect("the immediate parent is the reply context");
627 assert_eq!(reply.id, [0xcd; 32]);
628 assert_eq!(reply.author, Some(root_author.public_key()));
629 }
630
631 #[test]
632 fn an_armada_shaped_threaded_reply_parses_verbatim() {
633 let author = Keys::generate();
637 let root_author = Keys::generate();
638 let parent_author = Keys::generate();
639 let root_id = "ef".repeat(32);
640 let parent_id = "12".repeat(32);
641 let tags = vec![
642 Tag::custom(TagKind::Custom("channel".into()), [crate::simd::hex::bytes_to_hex_32(&chan().0)]),
643 Tag::custom(TagKind::Custom("epoch".into()), ["0".to_string()]),
644 Tag::custom(TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::K)), ["9".to_string()]),
645 Tag::custom(
646 TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::E)),
647 [root_id.clone(), String::new(), root_author.public_key().to_hex()],
648 ),
649 Tag::custom(TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::P)), [root_author.public_key().to_hex()]),
650 Tag::custom(TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::K)), ["1111".to_string()]),
651 Tag::custom(TagKind::e(), [parent_id.clone(), String::new(), parent_author.public_key().to_hex()]),
652 Tag::custom(TagKind::p(), [parent_author.public_key().to_hex()]),
653 ];
654 let rumor = stream::build_rumor_ms(kind::COMMENT, author.public_key(), "nested reply", tags, AT);
655 let ChatEvent::Message { reply_to, .. } = open(&seal(&rumor, &author)).unwrap() else {
656 panic!("expected a Message");
657 };
658 let reply = reply_to.expect("parent parses");
661 assert_eq!(reply.id, [0x12; 32]);
662 assert_eq!(reply.author, Some(parent_author.public_key()));
663 }
664
665 #[test]
666 fn a_nested_comment_inherits_its_root_tags_verbatim() {
667 let author = Keys::generate();
668 let root_id = "ab".repeat(32);
669 let root_author_hex = Keys::generate().public_key().to_hex();
670 let parent_id = "cd".repeat(32);
671 let parent_author_hex = Keys::generate().public_key().to_hex();
672 let rumor = build_comment_rumor(
673 author.public_key(),
674 &chan(),
675 Epoch(0),
676 "deep",
677 &parent_id,
678 kind::COMMENT, &parent_author_hex,
680 Some((&root_id, kind::MESSAGE, &root_author_hex)),
681 &[],
682 AT,
683 );
684 assert!(rumor.tags.iter().any(|t| t.as_slice()[0] == "E" && t.as_slice()[1] == root_id));
687 assert!(rumor.tags.iter().any(|t| t.as_slice() == ["K", "9"]));
688 assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "1111"]));
689 assert!(rumor.tags.iter().any(|t| t.as_slice()[0] == "e" && t.as_slice()[1] == parent_id));
690 }
691
692 #[test]
693 fn a_comment_with_duplicate_parent_tags_is_rejected() {
694 let author = Keys::generate();
697 let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
698 for id in ["ab", "ff"] {
699 tags.push(Tag::custom(TagKind::e(), [
700 id.repeat(32),
701 String::new(),
702 Keys::generate().public_key().to_hex(),
703 ]));
704 }
705 let rumor = stream::build_rumor_ms(kind::COMMENT, author.public_key(), "ambiguous", tags, AT);
706 let got = open(&seal(&rumor, &author));
707 assert!(matches!(&got, Err(ChatError::DuplicateTag("e"))), "got: {got:?}");
708 }
709
710 #[test]
711 fn a_reaction_to_a_threaded_reply_carries_k_1111() {
712 let author = Keys::generate();
713 let rumor = build_reaction_rumor(
714 author.public_key(),
715 &chan(),
716 Epoch(0),
717 &"bc".repeat(32),
718 &Keys::generate().public_key().to_hex(),
719 kind::COMMENT,
720 "🔥",
721 None,
722 AT,
723 );
724 assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "1111"]), "the k tag names the target's kind");
725 assert!(matches!(open(&seal(&rumor, &author)).unwrap(), ChatEvent::Reaction { .. }));
726 }
727
728 #[test]
729 fn reaction_round_trip_and_nip25_shape() {
730 let author = Keys::generate();
731 let target_author = Keys::generate();
732 let target_id = "bc".repeat(32);
733 let rumor = build_reaction_rumor(
734 author.public_key(),
735 &chan(),
736 Epoch(0),
737 &target_id,
738 &target_author.public_key().to_hex(),
739 kind::MESSAGE,
740 "🔥",
741 None,
742 AT,
743 );
744 assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "9"]));
746
747 let ChatEvent::Reaction { opened, target, target_author: ta, emoji, emoji_url } =
748 open(&seal(&rumor, &author)).unwrap()
749 else {
750 panic!("expected a Reaction");
751 };
752 assert_eq!(opened.author, author.public_key());
753 assert_eq!(target, [0xbc; 32]);
754 assert_eq!(ta, target_author.public_key());
755 assert_eq!(emoji, "🔥");
756 assert_eq!(emoji_url, None);
757 assert_eq!(opened.at_ms, AT);
758 }
759
760 #[test]
761 fn reaction_custom_emoji_carries_the_nip30_url() {
762 let author = Keys::generate();
763 let rumor = build_reaction_rumor(
764 author.public_key(),
765 &chan(),
766 Epoch(0),
767 &"bc".repeat(32),
768 &Keys::generate().public_key().to_hex(),
769 kind::MESSAGE,
770 ":catJAM:",
771 Some(("catJAM", "https://x/cat.gif")),
772 AT,
773 );
774 let ChatEvent::Reaction { emoji, emoji_url, .. } = open(&seal(&rumor, &author)).unwrap() else {
775 panic!("expected a Reaction");
776 };
777 assert_eq!(emoji, ":catJAM:");
778 assert_eq!(emoji_url, Some("https://x/cat.gif".to_string()));
779 }
780
781 #[test]
782 fn delete_round_trip_and_optional_target_kind() {
783 let author = Keys::generate();
784 let rumor = build_delete_rumor(author.public_key(), &chan(), Epoch(0), &"cd".repeat(32), kind::MESSAGE, AT);
785 let ChatEvent::Delete { target, target_kind, .. } = open(&seal(&rumor, &author)).unwrap() else {
786 panic!("expected a Delete");
787 };
788 assert_eq!(target, [0xcd; 32]);
789 assert_eq!(target_kind, Some(kind::MESSAGE));
790
791 let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
793 tags.push(Tag::custom(TagKind::e(), ["cd".repeat(32)]));
794 let bare = stream::build_rumor_ms(kind::DELETE, author.public_key(), "", tags, AT);
795 let ChatEvent::Delete { target_kind, .. } = open(&seal(&bare, &author)).unwrap() else {
796 panic!("expected a Delete");
797 };
798 assert_eq!(target_kind, None);
799 }
800
801 #[test]
802 fn edit_round_trip_replaces_content() {
803 let author = Keys::generate();
804 let rumor = build_edit_rumor(author.public_key(), &chan(), Epoch(0), &"de".repeat(32), "fixed the typo", AT);
805 let ChatEvent::Edit { opened, target, new_content } = open(&seal(&rumor, &author)).unwrap() else {
806 panic!("expected an Edit");
807 };
808 assert_eq!(opened.author, author.public_key());
809 assert_eq!(target, [0xde; 32]);
810 assert_eq!(new_content, "fixed the typo");
811 }
812
813 #[test]
814 fn webxdc_round_trip_is_opaque() {
815 let author = Keys::generate();
816 let app_tag = Tag::custom(TagKind::Custom("xdc".into()), ["state-update".to_string()]);
817 let rumor = build_webxdc_rumor(
818 author.public_key(),
819 &chan(),
820 Epoch(0),
821 "{\"move\":\"e4\"}",
822 vec![app_tag.clone()],
823 AT,
824 );
825 let ChatEvent::Webxdc { opened } = open(&seal(&rumor, &author)).unwrap() else {
826 panic!("expected a Webxdc");
827 };
828 assert_eq!(opened.rumor.content, "{\"move\":\"e4\"}");
829 assert!(opened.rumor.tags.iter().any(|t| t.as_slice() == app_tag.as_slice()));
830 }
831
832 #[test]
833 fn typing_rides_ephemeral_and_wrap_tier_is_not_content_authority() {
834 let author = Keys::generate();
835 let typing = build_typing_rumor(author.public_key(), &chan(), Epoch(0), AT);
836 let (wrap, _) = seal_chat_rumor(&typing, &group(), &author, WRAP_AT, true).unwrap();
837 assert_eq!(wrap.kind.as_u16(), stream::KIND_WRAP_EPHEMERAL);
838 let ChatEvent::Typing { opened } = open(&wrap).unwrap() else {
839 panic!("expected a Typing");
840 };
841 assert_eq!(opened.author, author.public_key());
842 assert_eq!(opened.rumor.content, "");
843
844 let msg = build_message_rumor(author.public_key(), &chan(), Epoch(0), "live", None, &[], vec![], AT);
847 let (wrap, _) = seal_chat_rumor(&msg, &group(), &author, WRAP_AT, true).unwrap();
848 assert!(matches!(open(&wrap), Ok(ChatEvent::Message { .. })));
849 }
850
851 #[test]
852 fn wrong_channel_and_wrong_epoch_are_rejected() {
853 let author = Keys::generate();
854 let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), "x", None, &[], vec![], AT);
855 let wrap = seal(&rumor, &author);
856 assert!(matches!(
858 open_chat_event(&wrap, &group(), &ChannelId([0xcd; 32]), Epoch(0)),
859 Err(ChatError::Stream(StreamError::ChannelMismatch))
860 ));
861 let stale = build_message_rumor(author.public_key(), &chan(), Epoch(1), "x", None, &[], vec![], AT);
863 let wrap = seal(&stale, &author);
864 assert!(matches!(open(&wrap), Err(ChatError::Stream(StreamError::EpochMismatch))));
865 }
866
867 #[test]
868 fn reaction_bound_to_channel_a_under_channel_b_key_is_rejected() {
869 let author = Keys::generate();
873 let chan_a = ChannelId([0xaa; 32]);
874 let chan_b = ChannelId([0xbb; 32]);
875 let group_b = chat_group_key(&secret(), &chan_b, Epoch(0));
876 let rumor = build_reaction_rumor(
877 author.public_key(),
878 &chan_a,
879 Epoch(0),
880 &"bc".repeat(32),
881 &Keys::generate().public_key().to_hex(),
882 kind::MESSAGE,
883 "🔥",
884 None,
885 AT,
886 );
887 let (wrap, _) = seal_chat_rumor(&rumor, &group_b, &author, WRAP_AT, false).unwrap();
888 assert!(matches!(
889 open_chat_event(&wrap, &group_b, &chan_b, Epoch(0)),
890 Err(ChatError::Stream(StreamError::ChannelMismatch))
891 ));
892 }
893
894 #[test]
895 fn multi_epoch_opens_each_wrap_under_its_own_epoch() {
896 let author = Keys::generate();
897 let key0 = [1u8; 32];
898 let key1 = [2u8; 32];
899 let held = [(Epoch(0), key0), (Epoch(1), key1)];
900
901 let m0 = build_message_rumor(author.public_key(), &chan(), Epoch(0), "before the rekey", None, &[], vec![], AT);
902 let g0 = chat_group_key(&key0, &chan(), Epoch(0));
903 let (w0, _) = seal_chat_rumor(&m0, &g0, &author, WRAP_AT, false).unwrap();
904
905 let m1 = build_message_rumor(author.public_key(), &chan(), Epoch(1), "after the rekey", None, &[], vec![], AT + 1);
906 let g1 = chat_group_key(&key1, &chan(), Epoch(1));
907 let (w1, _) = seal_chat_rumor(&m1, &g1, &author, WRAP_AT, false).unwrap();
908
909 let (ev0, e0) = open_chat_event_multi(&w0, &held, &chan()).unwrap();
910 assert_eq!(e0, Epoch(0));
911 assert_eq!(ev0.opened().rumor.content, "before the rekey");
912 let (ev1, e1) = open_chat_event_multi(&w1, &held, &chan()).unwrap();
913 assert_eq!(e1, Epoch(1));
914 assert_eq!(ev1.opened().rumor.content, "after the rekey");
915 }
916
917 #[test]
918 fn multi_epoch_unheld_wrap_is_not_ours() {
919 let author = Keys::generate();
920 let held = [(Epoch(0), [1u8; 32]), (Epoch(1), [2u8; 32])];
921 let m2 = build_message_rumor(author.public_key(), &chan(), Epoch(2), "future", None, &[], vec![], AT);
922 let g2 = chat_group_key(&[3u8; 32], &chan(), Epoch(2));
923 let (w2, _) = seal_chat_rumor(&m2, &g2, &author, WRAP_AT, false).unwrap();
924 assert!(matches!(open_chat_event_multi(&w2, &held, &chan()), Err(ChatError::NoHeldEpoch)));
925 }
926
927 #[test]
928 fn multi_epoch_cross_epoch_splice_is_rejected() {
929 let author = Keys::generate();
933 let key0 = [1u8; 32];
934 let key1 = [2u8; 32];
935 let held = [(Epoch(0), key0), (Epoch(1), key1)];
936 let stale = build_message_rumor(author.public_key(), &chan(), Epoch(0), "replay", None, &[], vec![], AT);
937 let g1 = chat_group_key(&key1, &chan(), Epoch(1));
938 let (wrap, _) = seal_chat_rumor(&stale, &g1, &author, WRAP_AT, false).unwrap();
939 assert!(matches!(
940 open_chat_event_multi(&wrap, &held, &chan()),
941 Err(ChatError::Stream(StreamError::EpochMismatch))
942 ));
943 }
944
945 #[test]
946 fn duplicate_e_tag_on_a_reaction_is_rejected() {
947 let author = Keys::generate();
948 let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
949 tags.push(Tag::custom(TagKind::e(), ["aa".repeat(32)]));
950 tags.push(Tag::custom(TagKind::e(), ["bb".repeat(32)]));
951 tags.push(Tag::custom(TagKind::p(), [Keys::generate().public_key().to_hex()]));
952 let rumor = stream::build_rumor_ms(kind::REACTION, author.public_key(), "+", tags, AT);
953 assert!(matches!(
954 open(&seal(&rumor, &author)),
955 Err(ChatError::DuplicateTag(TAG_TARGET))
956 ));
957 }
958
959 #[test]
960 fn unknown_rumor_kind_is_rejected_on_both_sides() {
961 let author = Keys::generate();
962 let tags = stream::channel_binding_tags(&chan(), Epoch(0));
964 let rumor = stream::build_rumor_ms(3300, author.public_key(), "v1 ghost", tags, AT);
965 assert!(matches!(
966 seal_chat_rumor(&rumor, &group(), &author, WRAP_AT, false),
967 Err(ChatError::UnknownKind(3300))
968 ));
969 let seal = stream::build_seal(&rumor, SealForm::Encrypted, &group(), &author).unwrap();
971 let (wrap, _) = stream::wrap_seal(&seal, &group(), stream::KIND_WRAP, WRAP_AT).unwrap();
972 assert!(matches!(open(&wrap), Err(ChatError::UnknownKind(3300))));
973 }
974
975 #[test]
976 fn plaintext_sealed_chat_event_is_rejected() {
977 let author = Keys::generate();
978 let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), "leaky", None, &[], vec![], AT);
979 let seal = stream::build_seal(&rumor, SealForm::Plaintext, &group(), &author).unwrap();
980 let (wrap, _) = stream::wrap_seal(&seal, &group(), stream::KIND_WRAP, WRAP_AT).unwrap();
981 assert!(matches!(open(&wrap), Err(ChatError::NotEncryptedSealed)));
982 }
983
984 #[test]
985 fn malformed_targets_are_errors_not_panics() {
986 let author = Keys::generate();
987 let rumor = build_reaction_rumor(
989 author.public_key(),
990 &chan(),
991 Epoch(0),
992 &"zz".repeat(32),
993 &Keys::generate().public_key().to_hex(),
994 kind::MESSAGE,
995 "+",
996 None,
997 AT,
998 );
999 assert!(matches!(open(&seal(&rumor, &author)), Err(ChatError::BadTag(TAG_TARGET))));
1000 let rumor = build_message_rumor(
1002 author.public_key(),
1003 &chan(),
1004 Epoch(0),
1005 "x",
1006 Some(("abcd", &Keys::generate().public_key().to_hex())),
1007 &[],
1008 vec![],
1009 AT,
1010 );
1011 assert!(matches!(open(&seal(&rumor, &author)), Err(ChatError::BadTag(TAG_QUOTE))));
1012 let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
1014 tags.push(Tag::custom(TagKind::e(), ["cd".repeat(32)]));
1015 tags.push(Tag::custom(
1016 TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::K)),
1017 ["nine".to_string()],
1018 ));
1019 let rumor = stream::build_rumor_ms(kind::DELETE, author.public_key(), "", tags, AT);
1020 assert!(matches!(open(&seal(&rumor, &author)), Err(ChatError::BadTag(TAG_TARGET_KIND))));
1021 let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
1023 tags.push(Tag::custom(TagKind::e(), ["cd".repeat(32)]));
1024 let rumor = stream::build_rumor_ms(kind::REACTION, author.public_key(), "+", tags, AT);
1025 assert!(matches!(
1026 open(&seal(&rumor, &author)),
1027 Err(ChatError::MissingTag(TAG_TARGET_AUTHOR))
1028 ));
1029 }
1030}