Skip to main content

vector_core/community/v2/
chat.rs

1//! CORD-03 Chat Plane — a Channel's messages over the v2 stream envelope.
2//!
3//! Every chat action is an unsigned rumor in an **encrypted seal** (CORD-02 §5
4//! makes 20013 mandatory here — chat content must never be liftable as a
5//! standalone public event) inside a wrap at the Channel's stream address.
6//! The rumor kinds reuse standard Nostr shapes wherever one fits:
7//!   - kind 9 message (NIP-C7: content = text, replies via a `q` tag naming
8//!     the parent RUMOR id — never the outer wrap's, which differs per re-wrap)
9//!   - kind 7 reaction (NIP-25: `e`/`p`/`k` name the target)
10//!   - kind 5 delete (NIP-09: `e` = the author's own rumor id, `k` its kind)
11//!   - kind 3302 edit (`e` = own message rumor id, content = replacement text)
12//!   - kind 3310 WebXDC peer signal (payload opaque to the protocol)
13//!   - kind 23311 typing (ephemeral tier — rides the 21059 wrap)
14//!
15//! Two Vector inner-tag conventions carry over from v1: NIP-30
16//! `["emoji", shortcode, url]` tags and verbatim extra tags (NIP-92 `imeta`
17//! attachments) ride inside the signed rumor, so they are author-committed.
18//!
19//! Every rumor MUST commit `["channel", id]` + `["epoch", n]`, checked
20//! strict-equal against the coordinate whose key decrypted the wrap (CORD-03
21//! §3) — the rumor's own claim is never trusted, so a keyholder of two planes
22//! cannot re-seal a rumor across Channels or replay it across epochs.
23//!
24//! The wrap kind (1059/21059) is a transport tier, not a content authority:
25//! the open side admits any allowlisted rumor kind on either wrap and lets the
26//! rumor kind govern meaning. Publishers still MUST put typing on 21059
27//! (relays MUST NOT store it) — that is a send-side duty, not a read gate.
28
29use 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/// Errors from the chat plane layer (envelope errors ride inside).
45#[derive(Debug)]
46pub enum ChatError {
47    Stream(StreamError),
48    /// A chat rumor arrived in a plaintext seal — CORD-02 §5 requires the
49    /// encrypted form on this plane, a strict reader drops the violation.
50    NotEncryptedSealed,
51    /// The rumor kind isn't in the chat-plane registry (retired numbers stay
52    /// burned — a 3300 is v1 traffic, never a v2 message).
53    UnknownKind(u16),
54    MissingTag(&'static str),
55    /// A target-bearing tag appears more than once — ambiguous, rejected
56    /// (same discipline as the stream module's binding tags).
57    DuplicateTag(&'static str),
58    /// A tag value failed its shape check (64-hex id, pubkey, or integer kind).
59    BadTag(&'static str),
60    /// The wrap's author matches no held `(epoch, key)` — not ours to open.
61    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
86// ── Keying (CORD-03 §1) ──────────────────────────────────────────────────────
87
88/// A Channel's Chat Plane group key. `secret` is whatever feeds the Channel at
89/// this epoch: the `community_root` for a Public Channel (at the root epoch),
90/// or the Channel's independent key for a Private one (at its own channel
91/// epoch). The channel id inside the derivation gives every Channel a distinct
92/// address regardless of which secret feeds it.
93pub fn chat_group_key(secret: &[u8; 32], channel_id: &ChannelId, epoch: Epoch) -> GroupKey {
94    channel_group_key(secret, channel_id, epoch)
95}
96
97// ── Rumor builders ───────────────────────────────────────────────────────────
98//
99// All builders take the author's pubkey (not keys) so bunker accounts build
100// identical rumors — signing happens at the seal, not here. `at_ms` is the
101// full epoch-ms send time (CORD-02 §4); the binding tags are always attached.
102
103/// Build a kind-9 message rumor. `reply_to` is the parent's
104/// `(rumor_id_hex, author_hex)` — the NIP-C7 `q` tag; `emoji` the NIP-30
105/// `(shortcode, url)` pairs for any `:shortcode:` in the content; `extra_tags`
106/// ride verbatim (NIP-92 `imeta` attachments), author-committed by the seal.
107#[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/// Build a kind-1111 threaded-reply rumor (NIP-22, CORD-03 §3). Uppercase
133/// `K`/`E`/`P` pin the immutable thread ROOT, lowercase `k`/`e`/`p` the
134/// immediate PARENT — all rumor ids. `parent_root` names the parent's own root
135/// when the parent is itself a reply (inherited verbatim, so the root stays
136/// stable at any depth — the exact shape Armada builds); `None` means the
137/// parent IS the root.
138#[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/// Build a kind-7 reaction rumor (NIP-25): `e` = the target rumor id, `p` its
172/// author, `k` = the target's kind (`9` for a message, `1111` for a threaded
173/// reply). `emoji_content` is the reaction itself (`"+"`, an emoji, or a
174/// `:shortcode:`); `emoji` carries the NIP-30 pair when the content is a
175/// custom-emoji shortcode.
176#[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
201/// Build a kind-5 delete rumor (NIP-09): `e` = the author's OWN rumor id,
202/// `k` = its kind. Semantic within the plane only — members stop rendering;
203/// the wrap ciphertext on relays needs a separate NIP-09 scrub by its `p` tag.
204pub 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
221/// Build a kind-3302 edit rumor: `e` = the author's own message rumor id,
222/// content = the replacement text (fields unpinned upstream; this shape
223/// matches the CORD examples).
224pub 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
237/// Build a kind-3310 WebXDC peer-signal rumor: `content` and `extra_tags` are
238/// the app payload, opaque to the protocol, carried verbatim.
239pub 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
252/// Build a kind-23311 typing rumor — presence of the event is the signal, it
253/// carries nothing. Seal it with `ephemeral: true` so relays never store it.
254pub 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
259// ── Seal / open over the stream ──────────────────────────────────────────────
260
261/// Seal a chat rumor into its wrap: encrypted seal (mandatory on this plane),
262/// then the durable 1059 wrap — or the ephemeral 21059 when `ephemeral` (the
263/// typing tier). Refuses non-chat rumor kinds so a control edition can never
264/// be published onto a Channel's stream by mistake. Returns the wrap plus the
265/// ephemeral `p` keypair (retain it to best-effort NIP-09-scrub the wrap
266/// later). Local-keys convenience; bunker accounts use [`stream::seal_content`]
267/// + their remote signer + [`stream::wrap_seal`] for identical wire output.
268pub 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/// A parsed reply reference — the NIP-C7 `q` tag's parent rumor id and (when
285/// carried, a SHOULD upstream) its author.
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct ReplyRef {
288    pub id: [u8; 32],
289    pub author: Option<PublicKey>,
290}
291
292/// A fully verified, typed chat event. Every variant keeps its
293/// [`OpenedStream`] — the proven author, rumor id, and ms time live there.
294#[derive(Debug, Clone)]
295pub enum ChatEvent {
296    /// Kind 9 (message; `reply_to` = its `q` inline quote) or kind 1111
297    /// (threaded reply; `reply_to` = its immediate parent, the lowercase `e`).
298    /// Text lives in `opened.rumor.content`; `emoji` is the NIP-30
299    /// `(shortcode, url)` pairs (attachments ride the rumor's `imeta` tags).
300    /// `opened.rumor.kind` distinguishes the two when a caller needs to.
301    Message {
302        opened: OpenedStream,
303        reply_to: Option<ReplyRef>,
304        emoji: Vec<(String, String)>,
305    },
306    /// Kind 7 — `emoji` is the reaction content; `emoji_url` the NIP-30 image
307    /// when the content is a custom `:shortcode:`.
308    Reaction {
309        opened: OpenedStream,
310        target: [u8; 32],
311        target_author: PublicKey,
312        emoji: String,
313        emoji_url: Option<String>,
314    },
315    /// Kind 5 — authorization (self, or a moderator) is the fold's job, not
316    /// the envelope's.
317    Delete {
318        opened: OpenedStream,
319        target: [u8; 32],
320        target_kind: Option<u16>,
321    },
322    /// Kind 3302 — author-only validity is likewise judged at fold time.
323    Edit {
324        opened: OpenedStream,
325        target: [u8; 32],
326        new_content: String,
327    },
328    /// Kind 3310 — payload opaque, read `opened.rumor` directly.
329    Webxdc { opened: OpenedStream },
330    /// Kind 23311 — the event's presence is the whole signal.
331    Typing { opened: OpenedStream },
332}
333
334impl ChatEvent {
335    /// The verified envelope facts (author, ms, rumor id) behind any variant.
336    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
348/// Open and fully verify one chat wrap against the `(channel, epoch)` whose
349/// key is being tried: envelope verification ([`stream::open_wrap`]), the
350/// encrypted-seal gate, the strict channel/epoch binding, the kind allowlist,
351/// then the typed parse. Malformed targets are errors, never panics.
352pub 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
366/// Open a chat wrap against every `(epoch, secret)` a client holds for one
367/// Channel — the CORD-03 §3 read path, where history spanning a rekey is
368/// queried across all held epoch pubkeys. Selection is by the wrap's author
369/// (each epoch's derived address), NEVER trial decryption, and the binding is
370/// then enforced against the epoch that actually matched — so an epoch-N rumor
371/// re-sealed under epoch M's key still dies as a splice.
372///
373/// `secret` per entry is whatever feeds the Channel at that epoch (CORD-03 §1:
374/// `community_root` for Public epochs, the channel key for Private ones).
375/// Derivation costs an HKDF + keypair per entry per call — batch readers
376/// should derive their [`GroupKey`]s once and match `wrap.pubkey` themselves.
377pub 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
391// ── Parse (rumor → typed event) ──────────────────────────────────────────────
392
393fn 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
400/// Type an ALREADY-VERIFIED rumor (one produced by [`stream::open_wrap`] and
401/// binding-checked). Target ids come from UNIQUE tags — a duplicated `e`/`p`/
402/// `q` is ambiguous (which target did the author sign off on?) and rejected.
403fn 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                    // NIP-C7 q tag: [q, id, relay-hint, pubkey] — the author
411                    // slot is a SHOULD; absent or empty parses as unknown.
412                    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            // A threaded reply (NIP-22, CORD-03 §3). Vector's timeline renders it
424            // INLINE: the immediate parent (the lowercase `e`) becomes the reply
425            // context, exactly like a kind-9 quote — never dropped. The uppercase
426            // root tags stay on the rumor for a future thread view.
427            let reply_to = match unique_tag(&opened.rumor, TAG_TARGET)? {
428                None => None, // a parentless comment still renders as plain text.
429                Some(s) => {
430                    let id = decode_id32(value_of(s, TAG_TARGET)?, TAG_TARGET)?;
431                    // NIP-22 e tag: [e, id, relay-hint, pubkey] — author optional.
432                    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
473// ── Tag helpers ──────────────────────────────────────────────────────────────
474
475fn emoji_tag(shortcode: &str, url: &str) -> Tag {
476    Tag::custom(TagKind::Custom(TAG_EMOJI.into()), [shortcode.to_string(), url.to_string()])
477}
478
479/// The unique tag named `name`, or None. More than one match = ambiguous,
480/// rejected (any keyholder can craft a rumor; first-match is nondeterministic).
481fn 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
495/// The unique tag's value, requiring the tag to be present.
496fn 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
512/// All well-formed NIP-30 `(shortcode, url)` pairs; malformed emoji tags are
513/// skipped, not fatal (worst case the raw `:shortcode:` text renders).
514fn 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        // The imeta tag rides the signed rumor byte-verbatim.
584        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        // CORD-03 §3 kind-1111: Vector renders a thread reply inline — the
601        // immediate parent (lowercase e) is the reply context, never dropped.
602        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, // the parent IS the root
614            &[],
615            AT,
616        );
617        // The wire shape matches the spec example: K/E/P root + k/e/p parent.
618        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        // The EXACT tag shape from the spec example (examples.md §2.2) built by
634        // hand — proving we parse the cross-client wire form, not just our own
635        // builder's output.
636        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        // A NESTED reply: the immediate parent (lowercase e), not the root, is
659        // the inline context.
660        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, // the parent is itself a reply
679            &parent_author_hex,
680            Some((&root_id, kind::MESSAGE, &root_author_hex)),
681            &[],
682            AT,
683        );
684        // Root pinned to the ORIGINAL root (stable at any depth), parent to the
685        // immediate reply.
686        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        // Two lowercase `e` tags = ambiguous parent (which did the author sign
695        // off on?) — same discipline as every target-bearing tag.
696        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        // NIP-25 shape: the k tag names the reacted-to kind.
745        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        // A k-less delete (the tag is optional in NIP-09) parses with None.
792        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        // A kind-9 on a 21059 wrap still opens: the wrap kind is a transport
845        // tier (storage policy), the rumor-kind allowlist governs content.
846        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        // Same key, wrong claimed coordinate: the strict-equal binding gates it.
857        assert!(matches!(
858            open_chat_event(&wrap, &group(), &ChannelId([0xcd; 32]), Epoch(0)),
859            Err(ChatError::Stream(StreamError::ChannelMismatch))
860        ));
861        // A rumor bound to epoch 1 sealed under epoch 0's key (replay shape).
862        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        // Cross-channel splice: a keyholder of both channels re-seals a rumor
870        // bound to A under B's key — the binding must be judged against the
871        // key that decrypted, never the rumor's own claim.
872        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        // A rumor bound to epoch 0 re-sealed under epoch 1's key: selection by
930        // wrap author picks epoch 1, and the binding must then fail — held-key
931        // selection can never launder a stale-epoch rumor.
932        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        // 3300 is a RETIRED v1 number — burned forever, never a v2 chat kind.
963        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        // And a wrap hand-built around it (bypassing the send gate) dies on open.
970        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        // Reaction: 64 chars but not hex.
988        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        // Message: truncated q id.
1001        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        // Delete: a k tag that isn't an integer kind.
1013        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        // Reaction missing its p target author entirely.
1022        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}