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(
250 author: PublicKey,
251 channel_id: &ChannelId,
252 epoch: Epoch,
253 target_rumor_id_hex: &str,
254 new_content: &str,
255 emoji: &[(&str, &str)],
256 at_ms: u64,
257) -> UnsignedEvent {
258 let mut tags = stream::channel_binding_tags(channel_id, epoch);
259 tags.push(Tag::custom("e", [target_rumor_id_hex.to_string()]));
260 for (shortcode, url) in emoji {
261 tags.push(emoji_tag(shortcode, url));
262 }
263 stream::build_rumor_ms(kind::EDIT, author, new_content, tags, at_ms)
264}
265
266pub fn build_webxdc_rumor(
269 author: PublicKey,
270 channel_id: &ChannelId,
271 epoch: Epoch,
272 content: &str,
273 extra_tags: Vec<Tag>,
274 at_ms: u64,
275) -> UnsignedEvent {
276 let mut tags = stream::channel_binding_tags(channel_id, epoch);
277 tags.extend(extra_tags);
278 stream::build_rumor_ms(kind::WEBXDC, author, content, tags, at_ms)
279}
280
281pub fn build_typing_rumor(author: PublicKey, channel_id: &ChannelId, epoch: Epoch, at_ms: u64) -> UnsignedEvent {
284 let tags = stream::channel_binding_tags(channel_id, epoch);
285 stream::build_rumor_ms(kind::TYPING, author, "", tags, at_ms)
286}
287
288pub fn seal_chat_rumor(
298 rumor: &UnsignedEvent,
299 group: &GroupKey,
300 author_keys: &Keys,
301 wrap_at: Timestamp,
302 ephemeral: bool,
303) -> Result<(Event, Keys), ChatError> {
304 let k = rumor.kind.as_u16();
305 if !is_chat_kind(k) {
306 return Err(ChatError::UnknownKind(k));
307 }
308 let seal = stream::build_seal(rumor, SealForm::Encrypted, group, author_keys)?;
309 let wrap_kind = if ephemeral { stream::KIND_WRAP_EPHEMERAL } else { stream::KIND_WRAP };
310 let wrap_extra: Vec<Tag> = rumor
314 .tags
315 .iter()
316 .filter(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false))
317 .cloned()
318 .collect();
319 Ok(stream::wrap_seal_with_tags(&seal, group, wrap_kind, wrap_at, &wrap_extra)?)
320}
321
322pub async fn seal_chat_rumor_signed<S: crate::signer::VectorSigner + ?Sized>(
326 signer: &S,
327 author: nostr_sdk::prelude::PublicKey,
328 rumor: &UnsignedEvent,
329 group: &GroupKey,
330 wrap_at: Timestamp,
331 ephemeral: bool,
332) -> Result<(Event, Keys), ChatError> {
333 let k = rumor.kind.as_u16();
334 if !is_chat_kind(k) {
335 return Err(ChatError::UnknownKind(k));
336 }
337 let wrap_kind = if ephemeral { stream::KIND_WRAP_EPHEMERAL } else { stream::KIND_WRAP };
338 let wrap_extra: Vec<Tag> = rumor
339 .tags
340 .iter()
341 .filter(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false))
342 .cloned()
343 .collect();
344 Ok(stream::seal_and_wrap_signed(signer, author, rumor, SealForm::Encrypted, group, wrap_kind, wrap_at, &wrap_extra).await?)
345}
346
347#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct ReplyRef {
351 pub id: [u8; 32],
352 pub author: Option<PublicKey>,
353}
354
355#[derive(Debug, Clone)]
358pub enum ChatEvent {
359 Message {
365 opened: OpenedStream,
366 reply_to: Option<ReplyRef>,
367 emoji: Vec<(String, String)>,
368 },
369 Reaction {
372 opened: OpenedStream,
373 target: [u8; 32],
374 target_author: PublicKey,
375 emoji: String,
376 emoji_url: Option<String>,
377 },
378 Delete {
381 opened: OpenedStream,
382 target: [u8; 32],
383 target_kind: Option<u16>,
384 },
385 Edit {
389 opened: OpenedStream,
390 target: [u8; 32],
391 new_content: String,
392 emoji: Vec<(String, String)>,
393 },
394 Webxdc { opened: OpenedStream },
396 Typing { opened: OpenedStream },
398}
399
400impl ChatEvent {
401 pub fn opened(&self) -> &OpenedStream {
403 match self {
404 ChatEvent::Message { opened, .. }
405 | ChatEvent::Reaction { opened, .. }
406 | ChatEvent::Delete { opened, .. }
407 | ChatEvent::Edit { opened, .. }
408 | ChatEvent::Webxdc { opened }
409 | ChatEvent::Typing { opened } => opened,
410 }
411 }
412}
413
414pub fn open_chat_event(
419 wrap: &Event,
420 group: &GroupKey,
421 channel_id: &ChannelId,
422 epoch: Epoch,
423) -> Result<ChatEvent, ChatError> {
424 let opened = stream::open_wrap(wrap, group)?;
425 if opened.seal_form != SealForm::Encrypted {
426 return Err(ChatError::NotEncryptedSealed);
427 }
428 stream::check_channel_binding(&opened.rumor, channel_id, epoch)?;
429 parse_chat_rumor(opened)
430}
431
432pub fn open_chat_event_multi(
444 wrap: &Event,
445 held: &[(Epoch, [u8; 32])],
446 channel_id: &ChannelId,
447) -> Result<(ChatEvent, Epoch), ChatError> {
448 for (epoch, secret) in held {
449 let group = channel_group_key(secret, channel_id, *epoch);
450 if wrap.pubkey == group.pk() {
451 return open_chat_event(wrap, &group, channel_id, *epoch).map(|ev| (ev, *epoch));
452 }
453 }
454 Err(ChatError::NoHeldEpoch)
455}
456
457fn is_chat_kind(k: u16) -> bool {
460 matches!(
461 k,
462 kind::MESSAGE | kind::COMMENT | kind::REACTION | kind::DELETE | kind::EDIT | kind::WEBXDC | kind::TYPING
463 )
464}
465
466fn parse_chat_rumor(opened: OpenedStream) -> Result<ChatEvent, ChatError> {
470 match opened.rumor.kind.as_u16() {
471 kind::MESSAGE => {
472 let reply_to = match unique_tag(&opened.rumor, TAG_QUOTE)? {
473 None => None,
474 Some(s) => {
475 let id = decode_id32(value_of(s, TAG_QUOTE)?, TAG_QUOTE)?;
476 let author = match s.get(3).map(String::as_str).filter(|a| !a.is_empty()) {
479 Some(hex) => Some(PublicKey::from_hex(hex).map_err(|_| ChatError::BadTag(TAG_QUOTE))?),
480 None => None,
481 };
482 Some(ReplyRef { id, author })
483 }
484 };
485 let emoji = collect_emoji(&opened.rumor);
486 Ok(ChatEvent::Message { opened, reply_to, emoji })
487 }
488 kind::COMMENT => {
489 let reply_to = match unique_tag(&opened.rumor, TAG_TARGET)? {
494 None => None, Some(s) => {
496 let id = decode_id32(value_of(s, TAG_TARGET)?, TAG_TARGET)?;
497 let author = match s.get(3).map(String::as_str).filter(|a| !a.is_empty()) {
499 Some(hex) => Some(PublicKey::from_hex(hex).map_err(|_| ChatError::BadTag(TAG_TARGET))?),
500 None => None,
501 };
502 Some(ReplyRef { id, author })
503 }
504 };
505 let emoji = collect_emoji(&opened.rumor);
506 Ok(ChatEvent::Message { opened, reply_to, emoji })
507 }
508 kind::REACTION => {
509 let target = decode_id32(required_tag(&opened.rumor, TAG_TARGET)?, TAG_TARGET)?;
510 let target_author = PublicKey::from_hex(required_tag(&opened.rumor, TAG_TARGET_AUTHOR)?)
511 .map_err(|_| ChatError::BadTag(TAG_TARGET_AUTHOR))?;
512 let emoji_url = collect_emoji(&opened.rumor).into_iter().next().map(|(_, url)| url);
513 let emoji = opened.rumor.content.clone();
514 Ok(ChatEvent::Reaction { opened, target, target_author, emoji, emoji_url })
515 }
516 kind::DELETE => {
517 let target = decode_id32(required_tag(&opened.rumor, TAG_TARGET)?, TAG_TARGET)?;
518 let target_kind = match unique_tag(&opened.rumor, TAG_TARGET_KIND)? {
519 None => None,
520 Some(s) => Some(
521 value_of(s, TAG_TARGET_KIND)?
522 .parse::<u16>()
523 .map_err(|_| ChatError::BadTag(TAG_TARGET_KIND))?,
524 ),
525 };
526 Ok(ChatEvent::Delete { opened, target, target_kind })
527 }
528 kind::EDIT => {
529 let target = decode_id32(required_tag(&opened.rumor, TAG_TARGET)?, TAG_TARGET)?;
530 let new_content = opened.rumor.content.clone();
531 let emoji = collect_emoji(&opened.rumor);
532 Ok(ChatEvent::Edit { opened, target, new_content, emoji })
533 }
534 kind::WEBXDC => Ok(ChatEvent::Webxdc { opened }),
535 kind::TYPING => Ok(ChatEvent::Typing { opened }),
536 k => Err(ChatError::UnknownKind(k)),
537 }
538}
539
540fn emoji_tag(shortcode: &str, url: &str) -> Tag {
543 Tag::custom(TAG_EMOJI, [shortcode.to_string(), url.to_string()])
544}
545
546fn unique_tag<'a>(rumor: &'a UnsignedEvent, name: &'static str) -> Result<Option<&'a [String]>, ChatError> {
549 let mut found: Option<&[String]> = None;
550 for t in rumor.tags.iter() {
551 let s = t.as_slice();
552 if s.first().map(|n| n == name).unwrap_or(false) {
553 if found.is_some() {
554 return Err(ChatError::DuplicateTag(name));
555 }
556 found = Some(s);
557 }
558 }
559 Ok(found)
560}
561
562fn required_tag<'a>(rumor: &'a UnsignedEvent, name: &'static str) -> Result<&'a str, ChatError> {
564 let s = unique_tag(rumor, name)?.ok_or(ChatError::MissingTag(name))?;
565 value_of(s, name)
566}
567
568fn value_of<'a>(slice: &'a [String], name: &'static str) -> Result<&'a str, ChatError> {
569 slice.get(1).map(String::as_str).ok_or(ChatError::BadTag(name))
570}
571
572fn decode_id32(hex: &str, field: &'static str) -> Result<[u8; 32], ChatError> {
573 if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
574 return Err(ChatError::BadTag(field));
575 }
576 Ok(crate::simd::hex::hex_to_bytes_32(hex))
577}
578
579fn collect_emoji(rumor: &UnsignedEvent) -> Vec<(String, String)> {
582 rumor
583 .tags
584 .iter()
585 .filter_map(|t| {
586 let s = t.as_slice();
587 (s.len() >= 3 && s[0] == TAG_EMOJI).then(|| (s[1].clone(), s[2].clone()))
588 })
589 .collect()
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595
596 const AT: u64 = 1_686_840_217_417;
597 const WRAP_AT: Timestamp = Timestamp::from_secs(1_700_000_000);
598
599 fn chan() -> ChannelId {
600 ChannelId([0xab; 32])
601 }
602
603 fn secret() -> [u8; 32] {
604 [7u8; 32]
605 }
606
607 fn group() -> GroupKey {
608 chat_group_key(&secret(), &chan(), Epoch(0))
609 }
610
611 fn open(wrap: &Event) -> Result<ChatEvent, ChatError> {
612 open_chat_event(wrap, &group(), &chan(), Epoch(0))
613 }
614
615 fn seal(rumor: &UnsignedEvent, author: &Keys) -> Event {
616 seal_chat_rumor(rumor, &group(), author, WRAP_AT, false).unwrap().0
617 }
618
619 #[test]
620 fn message_round_trip_carries_reply_emoji_and_extra_tags_verbatim() {
621 let author = Keys::generate();
622 let parent = Keys::generate();
623 let parent_id = "aa".repeat(32);
624 let imeta = Tag::custom(
625 "imeta",
626 ["url https://x/f.png".to_string(), "m image/png".to_string()],
627 );
628 let rumor = build_message_rumor(
629 author.public_key(),
630 &chan(),
631 Epoch(0),
632 "welcome :catJAM:",
633 Some((&parent_id, &parent.public_key().to_hex())),
634 &[("catJAM", "https://x/cat.gif")],
635 vec![imeta.clone()],
636 AT,
637 );
638 let wrap = seal(&rumor, &author);
639
640 let ChatEvent::Message { opened, reply_to, emoji } = open(&wrap).unwrap() else {
641 panic!("expected a Message");
642 };
643 assert_eq!(opened.author, author.public_key());
644 assert_eq!(opened.rumor.content, "welcome :catJAM:");
645 assert_eq!(opened.at_ms, AT);
646 let reply = reply_to.expect("reply parses back");
647 assert_eq!(reply.id, [0xaa; 32]);
648 assert_eq!(reply.author, Some(parent.public_key()));
649 assert_eq!(emoji, vec![("catJAM".to_string(), "https://x/cat.gif".to_string())]);
650 assert!(opened.rumor.tags.iter().any(|t| t.as_slice() == imeta.as_slice()));
652 }
653
654 #[test]
655 fn self_destruct_expiration_mirrors_onto_the_wrap_and_round_trips_into_the_message() {
656 const EXP: u64 = 1_700_000_500;
661 let author = Keys::generate();
662 let me = author.public_key();
663
664 let rumor = build_message_rumor(
666 me,
667 &chan(),
668 Epoch(0),
669 "poof",
670 None,
671 &[],
672 vec![Tag::expiration(Timestamp::from_secs(EXP))],
673 AT,
674 );
675 assert_eq!(message_expiration(&rumor), Some(EXP), "inner rumor carries the expiry");
676
677 let wrap = seal(&rumor, &author);
678 let mirrored = wrap.tags.iter().any(|t| {
680 let s = t.as_slice();
681 s.len() >= 2 && s[0] == "expiration" && s[1] == EXP.to_string()
682 });
683 assert!(mirrored, "the outer wrap mirrors the NIP-40 expiration for relays");
684
685 let ChatEvent::Message { opened, reply_to, emoji } = open(&wrap).unwrap() else {
687 panic!("expected a Message");
688 };
689 let msg = crate::community::v2::inbound::chat_message_to_message(&opened, &reply_to, &emoji, &me);
690 assert_eq!(msg.expiration, Some(EXP), "the receiver's Message carries the expiry");
691
692 let plain = build_message_rumor(me, &chan(), Epoch(0), "forever", None, &[], vec![], AT);
694 assert_eq!(message_expiration(&plain), None);
695 let plain_wrap = seal(&plain, &author);
696 assert!(!plain_wrap
697 .tags
698 .iter()
699 .any(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false)));
700 let ChatEvent::Message { opened, reply_to, emoji } = open(&plain_wrap).unwrap() else {
701 panic!("expected a Message");
702 };
703 let plain_msg = crate::community::v2::inbound::chat_message_to_message(&opened, &reply_to, &emoji, &me);
704 assert_eq!(plain_msg.expiration, None);
705 }
706
707 #[test]
708 fn message_without_q_has_no_reply() {
709 let author = Keys::generate();
710 let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), "hi", None, &[], vec![], AT);
711 let ChatEvent::Message { reply_to, emoji, .. } = open(&seal(&rumor, &author)).unwrap() else {
712 panic!("expected a Message");
713 };
714 assert_eq!(reply_to, None);
715 assert!(emoji.is_empty());
716 }
717
718 #[test]
719 fn a_threaded_reply_round_trips_as_a_message_with_its_parent_as_reply_context() {
720 let author = Keys::generate();
723 let root_author = Keys::generate();
724 let root_id = "cd".repeat(32);
725 let rumor = build_comment_rumor(
726 author.public_key(),
727 &chan(),
728 Epoch(0),
729 "replying in the thread!",
730 &root_id,
731 kind::MESSAGE,
732 &root_author.public_key().to_hex(),
733 None, &[],
735 AT,
736 );
737 assert!(rumor.tags.iter().any(|t| t.as_slice() == ["K", "9"]));
739 assert!(rumor.tags.iter().any(|t| t.as_slice()[0] == "E" && t.as_slice()[1] == root_id));
740 assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "9"]));
741
742 let ChatEvent::Message { opened, reply_to, .. } = open(&seal(&rumor, &author)).unwrap() else {
743 panic!("a threaded reply parses as a Message");
744 };
745 assert_eq!(opened.rumor.kind.as_u16(), kind::COMMENT, "the wire kind is preserved on the rumor");
746 let reply = reply_to.expect("the immediate parent is the reply context");
747 assert_eq!(reply.id, [0xcd; 32]);
748 assert_eq!(reply.author, Some(root_author.public_key()));
749 }
750
751 #[test]
752 fn an_armada_shaped_threaded_reply_parses_verbatim() {
753 let author = Keys::generate();
757 let root_author = Keys::generate();
758 let parent_author = Keys::generate();
759 let root_id = "ef".repeat(32);
760 let parent_id = "12".repeat(32);
761 let tags = vec![
762 Tag::custom("channel", [crate::simd::hex::bytes_to_hex_32(&chan().0)]),
763 Tag::custom("epoch", ["0".to_string()]),
764 Tag::custom("K", ["9".to_string()]),
765 Tag::custom(
766 "E",
767 [root_id.clone(), String::new(), root_author.public_key().to_hex()],
768 ),
769 Tag::custom("P", [root_author.public_key().to_hex()]),
770 Tag::custom("k", ["1111".to_string()]),
771 Tag::custom("e", [parent_id.clone(), String::new(), parent_author.public_key().to_hex()]),
772 Tag::custom("p", [parent_author.public_key().to_hex()]),
773 ];
774 let rumor = stream::build_rumor_ms(kind::COMMENT, author.public_key(), "nested reply", tags, AT);
775 let ChatEvent::Message { reply_to, .. } = open(&seal(&rumor, &author)).unwrap() else {
776 panic!("expected a Message");
777 };
778 let reply = reply_to.expect("parent parses");
781 assert_eq!(reply.id, [0x12; 32]);
782 assert_eq!(reply.author, Some(parent_author.public_key()));
783 }
784
785 #[test]
786 fn a_nested_comment_inherits_its_root_tags_verbatim() {
787 let author = Keys::generate();
788 let root_id = "ab".repeat(32);
789 let root_author_hex = Keys::generate().public_key().to_hex();
790 let parent_id = "cd".repeat(32);
791 let parent_author_hex = Keys::generate().public_key().to_hex();
792 let rumor = build_comment_rumor(
793 author.public_key(),
794 &chan(),
795 Epoch(0),
796 "deep",
797 &parent_id,
798 kind::COMMENT, &parent_author_hex,
800 Some((&root_id, kind::MESSAGE, &root_author_hex)),
801 &[],
802 AT,
803 );
804 assert!(rumor.tags.iter().any(|t| t.as_slice()[0] == "E" && t.as_slice()[1] == root_id));
807 assert!(rumor.tags.iter().any(|t| t.as_slice() == ["K", "9"]));
808 assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "1111"]));
809 assert!(rumor.tags.iter().any(|t| t.as_slice()[0] == "e" && t.as_slice()[1] == parent_id));
810 }
811
812 #[test]
813 fn a_comment_with_duplicate_parent_tags_is_rejected() {
814 let author = Keys::generate();
817 let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
818 for id in ["ab", "ff"] {
819 tags.push(Tag::custom("e", [
820 id.repeat(32),
821 String::new(),
822 Keys::generate().public_key().to_hex(),
823 ]));
824 }
825 let rumor = stream::build_rumor_ms(kind::COMMENT, author.public_key(), "ambiguous", tags, AT);
826 let got = open(&seal(&rumor, &author));
827 assert!(matches!(&got, Err(ChatError::DuplicateTag("e"))), "got: {got:?}");
828 }
829
830 #[test]
831 fn a_reaction_to_a_threaded_reply_carries_k_1111() {
832 let author = Keys::generate();
833 let rumor = build_reaction_rumor(
834 author.public_key(),
835 &chan(),
836 Epoch(0),
837 &"bc".repeat(32),
838 &Keys::generate().public_key().to_hex(),
839 kind::COMMENT,
840 "🔥",
841 None,
842 AT,
843 );
844 assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "1111"]), "the k tag names the target's kind");
845 assert!(matches!(open(&seal(&rumor, &author)).unwrap(), ChatEvent::Reaction { .. }));
846 }
847
848 #[test]
849 fn reaction_round_trip_and_nip25_shape() {
850 let author = Keys::generate();
851 let target_author = Keys::generate();
852 let target_id = "bc".repeat(32);
853 let rumor = build_reaction_rumor(
854 author.public_key(),
855 &chan(),
856 Epoch(0),
857 &target_id,
858 &target_author.public_key().to_hex(),
859 kind::MESSAGE,
860 "🔥",
861 None,
862 AT,
863 );
864 assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "9"]));
866
867 let ChatEvent::Reaction { opened, target, target_author: ta, emoji, emoji_url } =
868 open(&seal(&rumor, &author)).unwrap()
869 else {
870 panic!("expected a Reaction");
871 };
872 assert_eq!(opened.author, author.public_key());
873 assert_eq!(target, [0xbc; 32]);
874 assert_eq!(ta, target_author.public_key());
875 assert_eq!(emoji, "🔥");
876 assert_eq!(emoji_url, None);
877 assert_eq!(opened.at_ms, AT);
878 }
879
880 #[test]
881 fn reaction_custom_emoji_carries_the_nip30_url() {
882 let author = Keys::generate();
883 let rumor = build_reaction_rumor(
884 author.public_key(),
885 &chan(),
886 Epoch(0),
887 &"bc".repeat(32),
888 &Keys::generate().public_key().to_hex(),
889 kind::MESSAGE,
890 ":catJAM:",
891 Some(("catJAM", "https://x/cat.gif")),
892 AT,
893 );
894 let ChatEvent::Reaction { emoji, emoji_url, .. } = open(&seal(&rumor, &author)).unwrap() else {
895 panic!("expected a Reaction");
896 };
897 assert_eq!(emoji, ":catJAM:");
898 assert_eq!(emoji_url, Some("https://x/cat.gif".to_string()));
899 }
900
901 #[test]
902 fn delete_round_trip_and_optional_target_kind() {
903 let author = Keys::generate();
904 let rumor = build_delete_rumor(author.public_key(), &chan(), Epoch(0), &"cd".repeat(32), kind::MESSAGE, AT, None);
905 let ChatEvent::Delete { target, target_kind, .. } = open(&seal(&rumor, &author)).unwrap() else {
906 panic!("expected a Delete");
907 };
908 assert_eq!(target, [0xcd; 32]);
909 assert_eq!(target_kind, Some(kind::MESSAGE));
910
911 let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
913 tags.push(Tag::custom("e", ["cd".repeat(32)]));
914 let bare = stream::build_rumor_ms(kind::DELETE, author.public_key(), "", tags, AT);
915 let ChatEvent::Delete { target_kind, .. } = open(&seal(&bare, &author)).unwrap() else {
916 panic!("expected a Delete");
917 };
918 assert_eq!(target_kind, None);
919 }
920
921 #[test]
922 fn edit_round_trip_replaces_content() {
923 let author = Keys::generate();
924 let rumor = build_edit_rumor(author.public_key(), &chan(), Epoch(0), &"de".repeat(32), "fixed the typo", &[], AT);
925 let ChatEvent::Edit { opened, target, new_content, .. } = open(&seal(&rumor, &author)).unwrap() else {
926 panic!("expected an Edit");
927 };
928 assert_eq!(opened.author, author.public_key());
929 assert_eq!(target, [0xde; 32]);
930 assert_eq!(new_content, "fixed the typo");
931 }
932
933 #[test]
934 fn webxdc_round_trip_is_opaque() {
935 let author = Keys::generate();
936 let app_tag = Tag::custom("xdc", ["state-update".to_string()]);
937 let rumor = build_webxdc_rumor(
938 author.public_key(),
939 &chan(),
940 Epoch(0),
941 "{\"move\":\"e4\"}",
942 vec![app_tag.clone()],
943 AT,
944 );
945 let ChatEvent::Webxdc { opened } = open(&seal(&rumor, &author)).unwrap() else {
946 panic!("expected a Webxdc");
947 };
948 assert_eq!(opened.rumor.content, "{\"move\":\"e4\"}");
949 assert!(opened.rumor.tags.iter().any(|t| t.as_slice() == app_tag.as_slice()));
950 }
951
952 #[test]
953 fn typing_rides_ephemeral_and_wrap_tier_is_not_content_authority() {
954 let author = Keys::generate();
955 let typing = build_typing_rumor(author.public_key(), &chan(), Epoch(0), AT);
956 let (wrap, _) = seal_chat_rumor(&typing, &group(), &author, WRAP_AT, true).unwrap();
957 assert_eq!(wrap.kind.as_u16(), stream::KIND_WRAP_EPHEMERAL);
958 let ChatEvent::Typing { opened } = open(&wrap).unwrap() else {
959 panic!("expected a Typing");
960 };
961 assert_eq!(opened.author, author.public_key());
962 assert_eq!(opened.rumor.content, "");
963
964 let msg = build_message_rumor(author.public_key(), &chan(), Epoch(0), "live", None, &[], vec![], AT);
967 let (wrap, _) = seal_chat_rumor(&msg, &group(), &author, WRAP_AT, true).unwrap();
968 assert!(matches!(open(&wrap), Ok(ChatEvent::Message { .. })));
969 }
970
971 #[test]
972 fn wrong_channel_and_wrong_epoch_are_rejected() {
973 let author = Keys::generate();
974 let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), "x", None, &[], vec![], AT);
975 let wrap = seal(&rumor, &author);
976 assert!(matches!(
978 open_chat_event(&wrap, &group(), &ChannelId([0xcd; 32]), Epoch(0)),
979 Err(ChatError::Stream(StreamError::ChannelMismatch))
980 ));
981 let stale = build_message_rumor(author.public_key(), &chan(), Epoch(1), "x", None, &[], vec![], AT);
983 let wrap = seal(&stale, &author);
984 assert!(matches!(open(&wrap), Err(ChatError::Stream(StreamError::EpochMismatch))));
985 }
986
987 #[test]
988 fn reaction_bound_to_channel_a_under_channel_b_key_is_rejected() {
989 let author = Keys::generate();
993 let chan_a = ChannelId([0xaa; 32]);
994 let chan_b = ChannelId([0xbb; 32]);
995 let group_b = chat_group_key(&secret(), &chan_b, Epoch(0));
996 let rumor = build_reaction_rumor(
997 author.public_key(),
998 &chan_a,
999 Epoch(0),
1000 &"bc".repeat(32),
1001 &Keys::generate().public_key().to_hex(),
1002 kind::MESSAGE,
1003 "🔥",
1004 None,
1005 AT,
1006 );
1007 let (wrap, _) = seal_chat_rumor(&rumor, &group_b, &author, WRAP_AT, false).unwrap();
1008 assert!(matches!(
1009 open_chat_event(&wrap, &group_b, &chan_b, Epoch(0)),
1010 Err(ChatError::Stream(StreamError::ChannelMismatch))
1011 ));
1012 }
1013
1014 #[test]
1015 fn multi_epoch_opens_each_wrap_under_its_own_epoch() {
1016 let author = Keys::generate();
1017 let key0 = [1u8; 32];
1018 let key1 = [2u8; 32];
1019 let held = [(Epoch(0), key0), (Epoch(1), key1)];
1020
1021 let m0 = build_message_rumor(author.public_key(), &chan(), Epoch(0), "before the rekey", None, &[], vec![], AT);
1022 let g0 = chat_group_key(&key0, &chan(), Epoch(0));
1023 let (w0, _) = seal_chat_rumor(&m0, &g0, &author, WRAP_AT, false).unwrap();
1024
1025 let m1 = build_message_rumor(author.public_key(), &chan(), Epoch(1), "after the rekey", None, &[], vec![], AT + 1);
1026 let g1 = chat_group_key(&key1, &chan(), Epoch(1));
1027 let (w1, _) = seal_chat_rumor(&m1, &g1, &author, WRAP_AT, false).unwrap();
1028
1029 let (ev0, e0) = open_chat_event_multi(&w0, &held, &chan()).unwrap();
1030 assert_eq!(e0, Epoch(0));
1031 assert_eq!(ev0.opened().rumor.content, "before the rekey");
1032 let (ev1, e1) = open_chat_event_multi(&w1, &held, &chan()).unwrap();
1033 assert_eq!(e1, Epoch(1));
1034 assert_eq!(ev1.opened().rumor.content, "after the rekey");
1035 }
1036
1037 #[test]
1038 fn multi_epoch_unheld_wrap_is_not_ours() {
1039 let author = Keys::generate();
1040 let held = [(Epoch(0), [1u8; 32]), (Epoch(1), [2u8; 32])];
1041 let m2 = build_message_rumor(author.public_key(), &chan(), Epoch(2), "future", None, &[], vec![], AT);
1042 let g2 = chat_group_key(&[3u8; 32], &chan(), Epoch(2));
1043 let (w2, _) = seal_chat_rumor(&m2, &g2, &author, WRAP_AT, false).unwrap();
1044 assert!(matches!(open_chat_event_multi(&w2, &held, &chan()), Err(ChatError::NoHeldEpoch)));
1045 }
1046
1047 #[test]
1048 fn multi_epoch_cross_epoch_splice_is_rejected() {
1049 let author = Keys::generate();
1053 let key0 = [1u8; 32];
1054 let key1 = [2u8; 32];
1055 let held = [(Epoch(0), key0), (Epoch(1), key1)];
1056 let stale = build_message_rumor(author.public_key(), &chan(), Epoch(0), "replay", None, &[], vec![], AT);
1057 let g1 = chat_group_key(&key1, &chan(), Epoch(1));
1058 let (wrap, _) = seal_chat_rumor(&stale, &g1, &author, WRAP_AT, false).unwrap();
1059 assert!(matches!(
1060 open_chat_event_multi(&wrap, &held, &chan()),
1061 Err(ChatError::Stream(StreamError::EpochMismatch))
1062 ));
1063 }
1064
1065 #[test]
1066 fn duplicate_e_tag_on_a_reaction_is_rejected() {
1067 let author = Keys::generate();
1068 let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
1069 tags.push(Tag::custom("e", ["aa".repeat(32)]));
1070 tags.push(Tag::custom("e", ["bb".repeat(32)]));
1071 tags.push(Tag::custom("p", [Keys::generate().public_key().to_hex()]));
1072 let rumor = stream::build_rumor_ms(kind::REACTION, author.public_key(), "+", tags, AT);
1073 assert!(matches!(
1074 open(&seal(&rumor, &author)),
1075 Err(ChatError::DuplicateTag(TAG_TARGET))
1076 ));
1077 }
1078
1079 #[test]
1080 fn unknown_rumor_kind_is_rejected_on_both_sides() {
1081 let author = Keys::generate();
1082 let tags = stream::channel_binding_tags(&chan(), Epoch(0));
1084 let rumor = stream::build_rumor_ms(3300, author.public_key(), "v1 ghost", tags, AT);
1085 assert!(matches!(
1086 seal_chat_rumor(&rumor, &group(), &author, WRAP_AT, false),
1087 Err(ChatError::UnknownKind(3300))
1088 ));
1089 let seal = stream::build_seal(&rumor, SealForm::Encrypted, &group(), &author).unwrap();
1091 let (wrap, _) = stream::wrap_seal(&seal, &group(), stream::KIND_WRAP, WRAP_AT).unwrap();
1092 assert!(matches!(open(&wrap), Err(ChatError::UnknownKind(3300))));
1093 }
1094
1095 #[test]
1096 fn plaintext_sealed_chat_event_is_rejected() {
1097 let author = Keys::generate();
1098 let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), "leaky", None, &[], vec![], AT);
1099 let seal = stream::build_seal(&rumor, SealForm::Plaintext, &group(), &author).unwrap();
1100 let (wrap, _) = stream::wrap_seal(&seal, &group(), stream::KIND_WRAP, WRAP_AT).unwrap();
1101 assert!(matches!(open(&wrap), Err(ChatError::NotEncryptedSealed)));
1102 }
1103
1104 #[test]
1105 fn malformed_targets_are_errors_not_panics() {
1106 let author = Keys::generate();
1107 let rumor = build_reaction_rumor(
1109 author.public_key(),
1110 &chan(),
1111 Epoch(0),
1112 &"zz".repeat(32),
1113 &Keys::generate().public_key().to_hex(),
1114 kind::MESSAGE,
1115 "+",
1116 None,
1117 AT,
1118 );
1119 assert!(matches!(open(&seal(&rumor, &author)), Err(ChatError::BadTag(TAG_TARGET))));
1120 let rumor = build_message_rumor(
1122 author.public_key(),
1123 &chan(),
1124 Epoch(0),
1125 "x",
1126 Some(("abcd", &Keys::generate().public_key().to_hex())),
1127 &[],
1128 vec![],
1129 AT,
1130 );
1131 assert!(matches!(open(&seal(&rumor, &author)), Err(ChatError::BadTag(TAG_QUOTE))));
1132 let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
1134 tags.push(Tag::custom("e", ["cd".repeat(32)]));
1135 tags.push(Tag::custom(
1136 "k",
1137 ["nine".to_string()],
1138 ));
1139 let rumor = stream::build_rumor_ms(kind::DELETE, author.public_key(), "", tags, AT);
1140 assert!(matches!(open(&seal(&rumor, &author)), Err(ChatError::BadTag(TAG_TARGET_KIND))));
1141 let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
1143 tags.push(Tag::custom("e", ["cd".repeat(32)]));
1144 let rumor = stream::build_rumor_ms(kind::REACTION, author.public_key(), "+", tags, AT);
1145 assert!(matches!(
1146 open(&seal(&rumor, &author)),
1147 Err(ChatError::MissingTag(TAG_TARGET_AUTHOR))
1148 ));
1149 }
1150}