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/// Parse the NIP-40 `["expiration", <unix secs>]` tag off a chat rumor
133/// (Self-Destruct Timer). The inner twin of the wrap tag relays act on: this is
134/// what drives a receiver's local countdown + purge, and the only copy that
135/// survives a restart (the wrap is a discarded transport artifact).
136pub(crate) fn message_expiration(rumor: &UnsignedEvent) -> Option<u64> {
137    rumor.tags.iter().find_map(|tag| {
138        let s = tag.as_slice();
139        if s.len() >= 2 && s[0] == "expiration" {
140            s[1].parse::<u64>().ok()
141        } else {
142            None
143        }
144    })
145}
146
147/// Build a kind-1111 threaded-reply rumor (NIP-22, CORD-03 §3). Uppercase
148/// `K`/`E`/`P` pin the immutable thread ROOT, lowercase `k`/`e`/`p` the
149/// immediate PARENT — all rumor ids. `parent_root` names the parent's own root
150/// when the parent is itself a reply (inherited verbatim, so the root stays
151/// stable at any depth — the exact shape Armada builds); `None` means the
152/// parent IS the root.
153#[allow(clippy::too_many_arguments)]
154pub fn build_comment_rumor(
155    author: PublicKey,
156    channel_id: &ChannelId,
157    epoch: Epoch,
158    content: &str,
159    parent_id_hex: &str,
160    parent_kind: u16,
161    parent_author_hex: &str,
162    parent_root: Option<(&str, u16, &str)>,
163    emoji: &[(&str, &str)],
164    at_ms: u64,
165) -> UnsignedEvent {
166    let mut tags = stream::channel_binding_tags(channel_id, epoch);
167    let (root_id, root_kind, root_author) = parent_root.unwrap_or((parent_id_hex, parent_kind, parent_author_hex));
168    tags.push(Tag::custom(TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::K)), [root_kind.to_string()]));
169    tags.push(Tag::custom(
170        TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::E)),
171        [root_id.to_string(), String::new(), root_author.to_string()],
172    ));
173    tags.push(Tag::custom(TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::P)), [root_author.to_string()]));
174    tags.push(Tag::custom(TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::K)), [parent_kind.to_string()]));
175    tags.push(Tag::custom(
176        TagKind::e(),
177        [parent_id_hex.to_string(), String::new(), parent_author_hex.to_string()],
178    ));
179    tags.push(Tag::custom(TagKind::p(), [parent_author_hex.to_string()]));
180    for (shortcode, url) in emoji {
181        tags.push(emoji_tag(shortcode, url));
182    }
183    stream::build_rumor_ms(kind::COMMENT, author, content, tags, at_ms)
184}
185
186/// Build a kind-7 reaction rumor (NIP-25): `e` = the target rumor id, `p` its
187/// author, `k` = the target's kind (`9` for a message, `1111` for a threaded
188/// reply). `emoji_content` is the reaction itself (`"+"`, an emoji, or a
189/// `:shortcode:`); `emoji` carries the NIP-30 pair when the content is a
190/// custom-emoji shortcode.
191#[allow(clippy::too_many_arguments)]
192pub fn build_reaction_rumor(
193    author: PublicKey,
194    channel_id: &ChannelId,
195    epoch: Epoch,
196    target_rumor_id_hex: &str,
197    target_author_hex: &str,
198    target_kind: u16,
199    emoji_content: &str,
200    emoji: Option<(&str, &str)>,
201    at_ms: u64,
202) -> UnsignedEvent {
203    let mut tags = stream::channel_binding_tags(channel_id, epoch);
204    tags.push(Tag::custom(TagKind::e(), [target_rumor_id_hex.to_string()]));
205    tags.push(Tag::custom(TagKind::p(), [target_author_hex.to_string()]));
206    tags.push(Tag::custom(
207        TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::K)),
208        [target_kind.to_string()],
209    ));
210    if let Some((shortcode, url)) = emoji {
211        tags.push(emoji_tag(shortcode, url));
212    }
213    stream::build_rumor_ms(kind::REACTION, author, emoji_content, tags, at_ms)
214}
215
216/// Build a kind-5 delete rumor (NIP-09): `e` = the author's OWN rumor id,
217/// `k` = its kind. Semantic within the plane only — members stop rendering;
218/// the wrap ciphertext on relays needs a separate NIP-09 scrub by its `p` tag.
219pub fn build_delete_rumor(
220    author: PublicKey,
221    channel_id: &ChannelId,
222    epoch: Epoch,
223    target_rumor_id_hex: &str,
224    target_kind: u16,
225    at_ms: u64,
226) -> UnsignedEvent {
227    let mut tags = stream::channel_binding_tags(channel_id, epoch);
228    tags.push(Tag::custom(TagKind::e(), [target_rumor_id_hex.to_string()]));
229    tags.push(Tag::custom(
230        TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::K)),
231        [target_kind.to_string()],
232    ));
233    stream::build_rumor_ms(kind::DELETE, author, "", tags, at_ms)
234}
235
236/// Build a kind-3302 edit rumor: `e` = the author's own message rumor id,
237/// content = the replacement text (fields unpinned upstream; this shape
238/// matches the CORD examples).
239pub fn build_edit_rumor(
240    author: PublicKey,
241    channel_id: &ChannelId,
242    epoch: Epoch,
243    target_rumor_id_hex: &str,
244    new_content: &str,
245    at_ms: u64,
246) -> UnsignedEvent {
247    let mut tags = stream::channel_binding_tags(channel_id, epoch);
248    tags.push(Tag::custom(TagKind::e(), [target_rumor_id_hex.to_string()]));
249    stream::build_rumor_ms(kind::EDIT, author, new_content, tags, at_ms)
250}
251
252/// Build a kind-3310 WebXDC peer-signal rumor: `content` and `extra_tags` are
253/// the app payload, opaque to the protocol, carried verbatim.
254pub fn build_webxdc_rumor(
255    author: PublicKey,
256    channel_id: &ChannelId,
257    epoch: Epoch,
258    content: &str,
259    extra_tags: Vec<Tag>,
260    at_ms: u64,
261) -> UnsignedEvent {
262    let mut tags = stream::channel_binding_tags(channel_id, epoch);
263    tags.extend(extra_tags);
264    stream::build_rumor_ms(kind::WEBXDC, author, content, tags, at_ms)
265}
266
267/// Build a kind-23311 typing rumor — presence of the event is the signal, it
268/// carries nothing. Seal it with `ephemeral: true` so relays never store it.
269pub fn build_typing_rumor(author: PublicKey, channel_id: &ChannelId, epoch: Epoch, at_ms: u64) -> UnsignedEvent {
270    let tags = stream::channel_binding_tags(channel_id, epoch);
271    stream::build_rumor_ms(kind::TYPING, author, "", tags, at_ms)
272}
273
274// ── Seal / open over the stream ──────────────────────────────────────────────
275
276/// Seal a chat rumor into its wrap: encrypted seal (mandatory on this plane),
277/// then the durable 1059 wrap — or the ephemeral 21059 when `ephemeral` (the
278/// typing tier). Refuses non-chat rumor kinds so a control edition can never
279/// be published onto a Channel's stream by mistake. Returns the wrap plus the
280/// ephemeral `p` keypair (retain it to best-effort NIP-09-scrub the wrap
281/// later). Local-keys convenience; bunker accounts use [`stream::seal_content`]
282/// + their remote signer + [`stream::wrap_seal`] for identical wire output.
283pub fn seal_chat_rumor(
284    rumor: &UnsignedEvent,
285    group: &GroupKey,
286    author_keys: &Keys,
287    wrap_at: Timestamp,
288    ephemeral: bool,
289) -> Result<(Event, Keys), ChatError> {
290    let k = rumor.kind.as_u16();
291    if !is_chat_kind(k) {
292        return Err(ChatError::UnknownKind(k));
293    }
294    let seal = stream::build_seal(rumor, SealForm::Encrypted, group, author_keys)?;
295    let wrap_kind = if ephemeral { stream::KIND_WRAP_EPHEMERAL } else { stream::KIND_WRAP };
296    // Mirror a NIP-40 expiration (Self-Destruct Timer) from the inner rumor onto
297    // the outer wrap so relays drop the stored event on schedule; the inner copy
298    // drives each client's local purge. Only chat messages ever carry one.
299    let wrap_extra: Vec<Tag> = rumor
300        .tags
301        .iter()
302        .filter(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false))
303        .cloned()
304        .collect();
305    Ok(stream::wrap_seal_with_tags(&seal, group, wrap_kind, wrap_at, &wrap_extra)?)
306}
307
308/// A parsed reply reference — the NIP-C7 `q` tag's parent rumor id and (when
309/// carried, a SHOULD upstream) its author.
310#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct ReplyRef {
312    pub id: [u8; 32],
313    pub author: Option<PublicKey>,
314}
315
316/// A fully verified, typed chat event. Every variant keeps its
317/// [`OpenedStream`] — the proven author, rumor id, and ms time live there.
318#[derive(Debug, Clone)]
319pub enum ChatEvent {
320    /// Kind 9 (message; `reply_to` = its `q` inline quote) or kind 1111
321    /// (threaded reply; `reply_to` = its immediate parent, the lowercase `e`).
322    /// Text lives in `opened.rumor.content`; `emoji` is the NIP-30
323    /// `(shortcode, url)` pairs (attachments ride the rumor's `imeta` tags).
324    /// `opened.rumor.kind` distinguishes the two when a caller needs to.
325    Message {
326        opened: OpenedStream,
327        reply_to: Option<ReplyRef>,
328        emoji: Vec<(String, String)>,
329    },
330    /// Kind 7 — `emoji` is the reaction content; `emoji_url` the NIP-30 image
331    /// when the content is a custom `:shortcode:`.
332    Reaction {
333        opened: OpenedStream,
334        target: [u8; 32],
335        target_author: PublicKey,
336        emoji: String,
337        emoji_url: Option<String>,
338    },
339    /// Kind 5 — authorization (self, or a moderator) is the fold's job, not
340    /// the envelope's.
341    Delete {
342        opened: OpenedStream,
343        target: [u8; 32],
344        target_kind: Option<u16>,
345    },
346    /// Kind 3302 — author-only validity is likewise judged at fold time.
347    Edit {
348        opened: OpenedStream,
349        target: [u8; 32],
350        new_content: String,
351    },
352    /// Kind 3310 — payload opaque, read `opened.rumor` directly.
353    Webxdc { opened: OpenedStream },
354    /// Kind 23311 — the event's presence is the whole signal.
355    Typing { opened: OpenedStream },
356}
357
358impl ChatEvent {
359    /// The verified envelope facts (author, ms, rumor id) behind any variant.
360    pub fn opened(&self) -> &OpenedStream {
361        match self {
362            ChatEvent::Message { opened, .. }
363            | ChatEvent::Reaction { opened, .. }
364            | ChatEvent::Delete { opened, .. }
365            | ChatEvent::Edit { opened, .. }
366            | ChatEvent::Webxdc { opened }
367            | ChatEvent::Typing { opened } => opened,
368        }
369    }
370}
371
372/// Open and fully verify one chat wrap against the `(channel, epoch)` whose
373/// key is being tried: envelope verification ([`stream::open_wrap`]), the
374/// encrypted-seal gate, the strict channel/epoch binding, the kind allowlist,
375/// then the typed parse. Malformed targets are errors, never panics.
376pub fn open_chat_event(
377    wrap: &Event,
378    group: &GroupKey,
379    channel_id: &ChannelId,
380    epoch: Epoch,
381) -> Result<ChatEvent, ChatError> {
382    let opened = stream::open_wrap(wrap, group)?;
383    if opened.seal_form != SealForm::Encrypted {
384        return Err(ChatError::NotEncryptedSealed);
385    }
386    stream::check_channel_binding(&opened.rumor, channel_id, epoch)?;
387    parse_chat_rumor(opened)
388}
389
390/// Open a chat wrap against every `(epoch, secret)` a client holds for one
391/// Channel — the CORD-03 §3 read path, where history spanning a rekey is
392/// queried across all held epoch pubkeys. Selection is by the wrap's author
393/// (each epoch's derived address), NEVER trial decryption, and the binding is
394/// then enforced against the epoch that actually matched — so an epoch-N rumor
395/// re-sealed under epoch M's key still dies as a splice.
396///
397/// `secret` per entry is whatever feeds the Channel at that epoch (CORD-03 §1:
398/// `community_root` for Public epochs, the channel key for Private ones).
399/// Derivation costs an HKDF + keypair per entry per call — batch readers
400/// should derive their [`GroupKey`]s once and match `wrap.pubkey` themselves.
401pub fn open_chat_event_multi(
402    wrap: &Event,
403    held: &[(Epoch, [u8; 32])],
404    channel_id: &ChannelId,
405) -> Result<(ChatEvent, Epoch), ChatError> {
406    for (epoch, secret) in held {
407        let group = channel_group_key(secret, channel_id, *epoch);
408        if wrap.pubkey == group.pk() {
409            return open_chat_event(wrap, &group, channel_id, *epoch).map(|ev| (ev, *epoch));
410        }
411    }
412    Err(ChatError::NoHeldEpoch)
413}
414
415// ── Parse (rumor → typed event) ──────────────────────────────────────────────
416
417fn is_chat_kind(k: u16) -> bool {
418    matches!(
419        k,
420        kind::MESSAGE | kind::COMMENT | kind::REACTION | kind::DELETE | kind::EDIT | kind::WEBXDC | kind::TYPING
421    )
422}
423
424/// Type an ALREADY-VERIFIED rumor (one produced by [`stream::open_wrap`] and
425/// binding-checked). Target ids come from UNIQUE tags — a duplicated `e`/`p`/
426/// `q` is ambiguous (which target did the author sign off on?) and rejected.
427fn parse_chat_rumor(opened: OpenedStream) -> Result<ChatEvent, ChatError> {
428    match opened.rumor.kind.as_u16() {
429        kind::MESSAGE => {
430            let reply_to = match unique_tag(&opened.rumor, TAG_QUOTE)? {
431                None => None,
432                Some(s) => {
433                    let id = decode_id32(value_of(s, TAG_QUOTE)?, TAG_QUOTE)?;
434                    // NIP-C7 q tag: [q, id, relay-hint, pubkey] — the author
435                    // slot is a SHOULD; absent or empty parses as unknown.
436                    let author = match s.get(3).map(String::as_str).filter(|a| !a.is_empty()) {
437                        Some(hex) => Some(PublicKey::from_hex(hex).map_err(|_| ChatError::BadTag(TAG_QUOTE))?),
438                        None => None,
439                    };
440                    Some(ReplyRef { id, author })
441                }
442            };
443            let emoji = collect_emoji(&opened.rumor);
444            Ok(ChatEvent::Message { opened, reply_to, emoji })
445        }
446        kind::COMMENT => {
447            // A threaded reply (NIP-22, CORD-03 §3). Vector's timeline renders it
448            // INLINE: the immediate parent (the lowercase `e`) becomes the reply
449            // context, exactly like a kind-9 quote — never dropped. The uppercase
450            // root tags stay on the rumor for a future thread view.
451            let reply_to = match unique_tag(&opened.rumor, TAG_TARGET)? {
452                None => None, // a parentless comment still renders as plain text.
453                Some(s) => {
454                    let id = decode_id32(value_of(s, TAG_TARGET)?, TAG_TARGET)?;
455                    // NIP-22 e tag: [e, id, relay-hint, pubkey] — author optional.
456                    let author = match s.get(3).map(String::as_str).filter(|a| !a.is_empty()) {
457                        Some(hex) => Some(PublicKey::from_hex(hex).map_err(|_| ChatError::BadTag(TAG_TARGET))?),
458                        None => None,
459                    };
460                    Some(ReplyRef { id, author })
461                }
462            };
463            let emoji = collect_emoji(&opened.rumor);
464            Ok(ChatEvent::Message { opened, reply_to, emoji })
465        }
466        kind::REACTION => {
467            let target = decode_id32(required_tag(&opened.rumor, TAG_TARGET)?, TAG_TARGET)?;
468            let target_author = PublicKey::from_hex(required_tag(&opened.rumor, TAG_TARGET_AUTHOR)?)
469                .map_err(|_| ChatError::BadTag(TAG_TARGET_AUTHOR))?;
470            let emoji_url = collect_emoji(&opened.rumor).into_iter().next().map(|(_, url)| url);
471            let emoji = opened.rumor.content.clone();
472            Ok(ChatEvent::Reaction { opened, target, target_author, emoji, emoji_url })
473        }
474        kind::DELETE => {
475            let target = decode_id32(required_tag(&opened.rumor, TAG_TARGET)?, TAG_TARGET)?;
476            let target_kind = match unique_tag(&opened.rumor, TAG_TARGET_KIND)? {
477                None => None,
478                Some(s) => Some(
479                    value_of(s, TAG_TARGET_KIND)?
480                        .parse::<u16>()
481                        .map_err(|_| ChatError::BadTag(TAG_TARGET_KIND))?,
482                ),
483            };
484            Ok(ChatEvent::Delete { opened, target, target_kind })
485        }
486        kind::EDIT => {
487            let target = decode_id32(required_tag(&opened.rumor, TAG_TARGET)?, TAG_TARGET)?;
488            let new_content = opened.rumor.content.clone();
489            Ok(ChatEvent::Edit { opened, target, new_content })
490        }
491        kind::WEBXDC => Ok(ChatEvent::Webxdc { opened }),
492        kind::TYPING => Ok(ChatEvent::Typing { opened }),
493        k => Err(ChatError::UnknownKind(k)),
494    }
495}
496
497// ── Tag helpers ──────────────────────────────────────────────────────────────
498
499fn emoji_tag(shortcode: &str, url: &str) -> Tag {
500    Tag::custom(TagKind::Custom(TAG_EMOJI.into()), [shortcode.to_string(), url.to_string()])
501}
502
503/// The unique tag named `name`, or None. More than one match = ambiguous,
504/// rejected (any keyholder can craft a rumor; first-match is nondeterministic).
505fn unique_tag<'a>(rumor: &'a UnsignedEvent, name: &'static str) -> Result<Option<&'a [String]>, ChatError> {
506    let mut found: Option<&[String]> = None;
507    for t in rumor.tags.iter() {
508        let s = t.as_slice();
509        if s.first().map(|n| n == name).unwrap_or(false) {
510            if found.is_some() {
511                return Err(ChatError::DuplicateTag(name));
512            }
513            found = Some(s);
514        }
515    }
516    Ok(found)
517}
518
519/// The unique tag's value, requiring the tag to be present.
520fn required_tag<'a>(rumor: &'a UnsignedEvent, name: &'static str) -> Result<&'a str, ChatError> {
521    let s = unique_tag(rumor, name)?.ok_or(ChatError::MissingTag(name))?;
522    value_of(s, name)
523}
524
525fn value_of<'a>(slice: &'a [String], name: &'static str) -> Result<&'a str, ChatError> {
526    slice.get(1).map(String::as_str).ok_or(ChatError::BadTag(name))
527}
528
529fn decode_id32(hex: &str, field: &'static str) -> Result<[u8; 32], ChatError> {
530    if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
531        return Err(ChatError::BadTag(field));
532    }
533    Ok(crate::simd::hex::hex_to_bytes_32(hex))
534}
535
536/// All well-formed NIP-30 `(shortcode, url)` pairs; malformed emoji tags are
537/// skipped, not fatal (worst case the raw `:shortcode:` text renders).
538fn collect_emoji(rumor: &UnsignedEvent) -> Vec<(String, String)> {
539    rumor
540        .tags
541        .iter()
542        .filter_map(|t| {
543            let s = t.as_slice();
544            (s.len() >= 3 && s[0] == TAG_EMOJI).then(|| (s[1].clone(), s[2].clone()))
545        })
546        .collect()
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    const AT: u64 = 1_686_840_217_417;
554    const WRAP_AT: Timestamp = Timestamp::from_secs(1_700_000_000);
555
556    fn chan() -> ChannelId {
557        ChannelId([0xab; 32])
558    }
559
560    fn secret() -> [u8; 32] {
561        [7u8; 32]
562    }
563
564    fn group() -> GroupKey {
565        chat_group_key(&secret(), &chan(), Epoch(0))
566    }
567
568    fn open(wrap: &Event) -> Result<ChatEvent, ChatError> {
569        open_chat_event(wrap, &group(), &chan(), Epoch(0))
570    }
571
572    fn seal(rumor: &UnsignedEvent, author: &Keys) -> Event {
573        seal_chat_rumor(rumor, &group(), author, WRAP_AT, false).unwrap().0
574    }
575
576    #[test]
577    fn message_round_trip_carries_reply_emoji_and_extra_tags_verbatim() {
578        let author = Keys::generate();
579        let parent = Keys::generate();
580        let parent_id = "aa".repeat(32);
581        let imeta = Tag::custom(
582            TagKind::Custom("imeta".into()),
583            ["url https://x/f.png".to_string(), "m image/png".to_string()],
584        );
585        let rumor = build_message_rumor(
586            author.public_key(),
587            &chan(),
588            Epoch(0),
589            "welcome :catJAM:",
590            Some((&parent_id, &parent.public_key().to_hex())),
591            &[("catJAM", "https://x/cat.gif")],
592            vec![imeta.clone()],
593            AT,
594        );
595        let wrap = seal(&rumor, &author);
596
597        let ChatEvent::Message { opened, reply_to, emoji } = open(&wrap).unwrap() else {
598            panic!("expected a Message");
599        };
600        assert_eq!(opened.author, author.public_key());
601        assert_eq!(opened.rumor.content, "welcome :catJAM:");
602        assert_eq!(opened.at_ms, AT);
603        let reply = reply_to.expect("reply parses back");
604        assert_eq!(reply.id, [0xaa; 32]);
605        assert_eq!(reply.author, Some(parent.public_key()));
606        assert_eq!(emoji, vec![("catJAM".to_string(), "https://x/cat.gif".to_string())]);
607        // The imeta tag rides the signed rumor byte-verbatim.
608        assert!(opened.rumor.tags.iter().any(|t| t.as_slice() == imeta.as_slice()));
609    }
610
611    #[test]
612    fn self_destruct_expiration_mirrors_onto_the_wrap_and_round_trips_into_the_message() {
613        // NIP-40 Self-Destruct Timer: the expiry rides the inner rumor (drives
614        // every client's local purge + survives restart) AND is mirrored onto the
615        // outer wrap (so relays drop the stored event). Without a timer, neither
616        // layer carries a tag.
617        const EXP: u64 = 1_700_000_500;
618        let author = Keys::generate();
619        let me = author.public_key();
620
621        // Sender stamps the expiry as an extra tag (exactly how the send command does).
622        let rumor = build_message_rumor(
623            me,
624            &chan(),
625            Epoch(0),
626            "poof",
627            None,
628            &[],
629            vec![Tag::expiration(Timestamp::from_secs(EXP))],
630            AT,
631        );
632        assert_eq!(message_expiration(&rumor), Some(EXP), "inner rumor carries the expiry");
633
634        let wrap = seal(&rumor, &author);
635        // Relay-drop half: the outer wrap mirrors the expiration tag.
636        let mirrored = wrap.tags.iter().any(|t| {
637            let s = t.as_slice();
638            s.len() >= 2 && s[0] == "expiration" && s[1] == EXP.to_string()
639        });
640        assert!(mirrored, "the outer wrap mirrors the NIP-40 expiration for relays");
641
642        // Local-purge half: the receiver's opened Message carries the expiry.
643        let ChatEvent::Message { opened, reply_to, emoji } = open(&wrap).unwrap() else {
644            panic!("expected a Message");
645        };
646        let msg = crate::community::v2::inbound::chat_message_to_message(&opened, &reply_to, &emoji, &me);
647        assert_eq!(msg.expiration, Some(EXP), "the receiver's Message carries the expiry");
648
649        // Control: no timer → clean rumor, clean wrap, no Message expiry.
650        let plain = build_message_rumor(me, &chan(), Epoch(0), "forever", None, &[], vec![], AT);
651        assert_eq!(message_expiration(&plain), None);
652        let plain_wrap = seal(&plain, &author);
653        assert!(!plain_wrap
654            .tags
655            .iter()
656            .any(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false)));
657        let ChatEvent::Message { opened, reply_to, emoji } = open(&plain_wrap).unwrap() else {
658            panic!("expected a Message");
659        };
660        let plain_msg = crate::community::v2::inbound::chat_message_to_message(&opened, &reply_to, &emoji, &me);
661        assert_eq!(plain_msg.expiration, None);
662    }
663
664    #[test]
665    fn message_without_q_has_no_reply() {
666        let author = Keys::generate();
667        let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), "hi", None, &[], vec![], AT);
668        let ChatEvent::Message { reply_to, emoji, .. } = open(&seal(&rumor, &author)).unwrap() else {
669            panic!("expected a Message");
670        };
671        assert_eq!(reply_to, None);
672        assert!(emoji.is_empty());
673    }
674
675    #[test]
676    fn a_threaded_reply_round_trips_as_a_message_with_its_parent_as_reply_context() {
677        // CORD-03 §3 kind-1111: Vector renders a thread reply inline — the
678        // immediate parent (lowercase e) is the reply context, never dropped.
679        let author = Keys::generate();
680        let root_author = Keys::generate();
681        let root_id = "cd".repeat(32);
682        let rumor = build_comment_rumor(
683            author.public_key(),
684            &chan(),
685            Epoch(0),
686            "replying in the thread!",
687            &root_id,
688            kind::MESSAGE,
689            &root_author.public_key().to_hex(),
690            None, // the parent IS the root
691            &[],
692            AT,
693        );
694        // The wire shape matches the spec example: K/E/P root + k/e/p parent.
695        assert!(rumor.tags.iter().any(|t| t.as_slice() == ["K", "9"]));
696        assert!(rumor.tags.iter().any(|t| t.as_slice()[0] == "E" && t.as_slice()[1] == root_id));
697        assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "9"]));
698
699        let ChatEvent::Message { opened, reply_to, .. } = open(&seal(&rumor, &author)).unwrap() else {
700            panic!("a threaded reply parses as a Message");
701        };
702        assert_eq!(opened.rumor.kind.as_u16(), kind::COMMENT, "the wire kind is preserved on the rumor");
703        let reply = reply_to.expect("the immediate parent is the reply context");
704        assert_eq!(reply.id, [0xcd; 32]);
705        assert_eq!(reply.author, Some(root_author.public_key()));
706    }
707
708    #[test]
709    fn an_armada_shaped_threaded_reply_parses_verbatim() {
710        // The EXACT tag shape from the spec example (examples.md §2.2) built by
711        // hand — proving we parse the cross-client wire form, not just our own
712        // builder's output.
713        let author = Keys::generate();
714        let root_author = Keys::generate();
715        let parent_author = Keys::generate();
716        let root_id = "ef".repeat(32);
717        let parent_id = "12".repeat(32);
718        let tags = vec![
719            Tag::custom(TagKind::Custom("channel".into()), [crate::simd::hex::bytes_to_hex_32(&chan().0)]),
720            Tag::custom(TagKind::Custom("epoch".into()), ["0".to_string()]),
721            Tag::custom(TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::K)), ["9".to_string()]),
722            Tag::custom(
723                TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::E)),
724                [root_id.clone(), String::new(), root_author.public_key().to_hex()],
725            ),
726            Tag::custom(TagKind::SingleLetter(SingleLetterTag::uppercase(Alphabet::P)), [root_author.public_key().to_hex()]),
727            Tag::custom(TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::K)), ["1111".to_string()]),
728            Tag::custom(TagKind::e(), [parent_id.clone(), String::new(), parent_author.public_key().to_hex()]),
729            Tag::custom(TagKind::p(), [parent_author.public_key().to_hex()]),
730        ];
731        let rumor = stream::build_rumor_ms(kind::COMMENT, author.public_key(), "nested reply", tags, AT);
732        let ChatEvent::Message { reply_to, .. } = open(&seal(&rumor, &author)).unwrap() else {
733            panic!("expected a Message");
734        };
735        // A NESTED reply: the immediate parent (lowercase e), not the root, is
736        // the inline context.
737        let reply = reply_to.expect("parent parses");
738        assert_eq!(reply.id, [0x12; 32]);
739        assert_eq!(reply.author, Some(parent_author.public_key()));
740    }
741
742    #[test]
743    fn a_nested_comment_inherits_its_root_tags_verbatim() {
744        let author = Keys::generate();
745        let root_id = "ab".repeat(32);
746        let root_author_hex = Keys::generate().public_key().to_hex();
747        let parent_id = "cd".repeat(32);
748        let parent_author_hex = Keys::generate().public_key().to_hex();
749        let rumor = build_comment_rumor(
750            author.public_key(),
751            &chan(),
752            Epoch(0),
753            "deep",
754            &parent_id,
755            kind::COMMENT, // the parent is itself a reply
756            &parent_author_hex,
757            Some((&root_id, kind::MESSAGE, &root_author_hex)),
758            &[],
759            AT,
760        );
761        // Root pinned to the ORIGINAL root (stable at any depth), parent to the
762        // immediate reply.
763        assert!(rumor.tags.iter().any(|t| t.as_slice()[0] == "E" && t.as_slice()[1] == root_id));
764        assert!(rumor.tags.iter().any(|t| t.as_slice() == ["K", "9"]));
765        assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "1111"]));
766        assert!(rumor.tags.iter().any(|t| t.as_slice()[0] == "e" && t.as_slice()[1] == parent_id));
767    }
768
769    #[test]
770    fn a_comment_with_duplicate_parent_tags_is_rejected() {
771        // Two lowercase `e` tags = ambiguous parent (which did the author sign
772        // off on?) — same discipline as every target-bearing tag.
773        let author = Keys::generate();
774        let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
775        for id in ["ab", "ff"] {
776            tags.push(Tag::custom(TagKind::e(), [
777                id.repeat(32),
778                String::new(),
779                Keys::generate().public_key().to_hex(),
780            ]));
781        }
782        let rumor = stream::build_rumor_ms(kind::COMMENT, author.public_key(), "ambiguous", tags, AT);
783        let got = open(&seal(&rumor, &author));
784        assert!(matches!(&got, Err(ChatError::DuplicateTag("e"))), "got: {got:?}");
785    }
786
787    #[test]
788    fn a_reaction_to_a_threaded_reply_carries_k_1111() {
789        let author = Keys::generate();
790        let rumor = build_reaction_rumor(
791            author.public_key(),
792            &chan(),
793            Epoch(0),
794            &"bc".repeat(32),
795            &Keys::generate().public_key().to_hex(),
796            kind::COMMENT,
797            "🔥",
798            None,
799            AT,
800        );
801        assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "1111"]), "the k tag names the target's kind");
802        assert!(matches!(open(&seal(&rumor, &author)).unwrap(), ChatEvent::Reaction { .. }));
803    }
804
805    #[test]
806    fn reaction_round_trip_and_nip25_shape() {
807        let author = Keys::generate();
808        let target_author = Keys::generate();
809        let target_id = "bc".repeat(32);
810        let rumor = build_reaction_rumor(
811            author.public_key(),
812            &chan(),
813            Epoch(0),
814            &target_id,
815            &target_author.public_key().to_hex(),
816            kind::MESSAGE,
817            "🔥",
818            None,
819            AT,
820        );
821        // NIP-25 shape: the k tag names the reacted-to kind.
822        assert!(rumor.tags.iter().any(|t| t.as_slice() == ["k", "9"]));
823
824        let ChatEvent::Reaction { opened, target, target_author: ta, emoji, emoji_url } =
825            open(&seal(&rumor, &author)).unwrap()
826        else {
827            panic!("expected a Reaction");
828        };
829        assert_eq!(opened.author, author.public_key());
830        assert_eq!(target, [0xbc; 32]);
831        assert_eq!(ta, target_author.public_key());
832        assert_eq!(emoji, "🔥");
833        assert_eq!(emoji_url, None);
834        assert_eq!(opened.at_ms, AT);
835    }
836
837    #[test]
838    fn reaction_custom_emoji_carries_the_nip30_url() {
839        let author = Keys::generate();
840        let rumor = build_reaction_rumor(
841            author.public_key(),
842            &chan(),
843            Epoch(0),
844            &"bc".repeat(32),
845            &Keys::generate().public_key().to_hex(),
846            kind::MESSAGE,
847            ":catJAM:",
848            Some(("catJAM", "https://x/cat.gif")),
849            AT,
850        );
851        let ChatEvent::Reaction { emoji, emoji_url, .. } = open(&seal(&rumor, &author)).unwrap() else {
852            panic!("expected a Reaction");
853        };
854        assert_eq!(emoji, ":catJAM:");
855        assert_eq!(emoji_url, Some("https://x/cat.gif".to_string()));
856    }
857
858    #[test]
859    fn delete_round_trip_and_optional_target_kind() {
860        let author = Keys::generate();
861        let rumor = build_delete_rumor(author.public_key(), &chan(), Epoch(0), &"cd".repeat(32), kind::MESSAGE, AT);
862        let ChatEvent::Delete { target, target_kind, .. } = open(&seal(&rumor, &author)).unwrap() else {
863            panic!("expected a Delete");
864        };
865        assert_eq!(target, [0xcd; 32]);
866        assert_eq!(target_kind, Some(kind::MESSAGE));
867
868        // A k-less delete (the tag is optional in NIP-09) parses with None.
869        let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
870        tags.push(Tag::custom(TagKind::e(), ["cd".repeat(32)]));
871        let bare = stream::build_rumor_ms(kind::DELETE, author.public_key(), "", tags, AT);
872        let ChatEvent::Delete { target_kind, .. } = open(&seal(&bare, &author)).unwrap() else {
873            panic!("expected a Delete");
874        };
875        assert_eq!(target_kind, None);
876    }
877
878    #[test]
879    fn edit_round_trip_replaces_content() {
880        let author = Keys::generate();
881        let rumor = build_edit_rumor(author.public_key(), &chan(), Epoch(0), &"de".repeat(32), "fixed the typo", AT);
882        let ChatEvent::Edit { opened, target, new_content } = open(&seal(&rumor, &author)).unwrap() else {
883            panic!("expected an Edit");
884        };
885        assert_eq!(opened.author, author.public_key());
886        assert_eq!(target, [0xde; 32]);
887        assert_eq!(new_content, "fixed the typo");
888    }
889
890    #[test]
891    fn webxdc_round_trip_is_opaque() {
892        let author = Keys::generate();
893        let app_tag = Tag::custom(TagKind::Custom("xdc".into()), ["state-update".to_string()]);
894        let rumor = build_webxdc_rumor(
895            author.public_key(),
896            &chan(),
897            Epoch(0),
898            "{\"move\":\"e4\"}",
899            vec![app_tag.clone()],
900            AT,
901        );
902        let ChatEvent::Webxdc { opened } = open(&seal(&rumor, &author)).unwrap() else {
903            panic!("expected a Webxdc");
904        };
905        assert_eq!(opened.rumor.content, "{\"move\":\"e4\"}");
906        assert!(opened.rumor.tags.iter().any(|t| t.as_slice() == app_tag.as_slice()));
907    }
908
909    #[test]
910    fn typing_rides_ephemeral_and_wrap_tier_is_not_content_authority() {
911        let author = Keys::generate();
912        let typing = build_typing_rumor(author.public_key(), &chan(), Epoch(0), AT);
913        let (wrap, _) = seal_chat_rumor(&typing, &group(), &author, WRAP_AT, true).unwrap();
914        assert_eq!(wrap.kind.as_u16(), stream::KIND_WRAP_EPHEMERAL);
915        let ChatEvent::Typing { opened } = open(&wrap).unwrap() else {
916            panic!("expected a Typing");
917        };
918        assert_eq!(opened.author, author.public_key());
919        assert_eq!(opened.rumor.content, "");
920
921        // A kind-9 on a 21059 wrap still opens: the wrap kind is a transport
922        // tier (storage policy), the rumor-kind allowlist governs content.
923        let msg = build_message_rumor(author.public_key(), &chan(), Epoch(0), "live", None, &[], vec![], AT);
924        let (wrap, _) = seal_chat_rumor(&msg, &group(), &author, WRAP_AT, true).unwrap();
925        assert!(matches!(open(&wrap), Ok(ChatEvent::Message { .. })));
926    }
927
928    #[test]
929    fn wrong_channel_and_wrong_epoch_are_rejected() {
930        let author = Keys::generate();
931        let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), "x", None, &[], vec![], AT);
932        let wrap = seal(&rumor, &author);
933        // Same key, wrong claimed coordinate: the strict-equal binding gates it.
934        assert!(matches!(
935            open_chat_event(&wrap, &group(), &ChannelId([0xcd; 32]), Epoch(0)),
936            Err(ChatError::Stream(StreamError::ChannelMismatch))
937        ));
938        // A rumor bound to epoch 1 sealed under epoch 0's key (replay shape).
939        let stale = build_message_rumor(author.public_key(), &chan(), Epoch(1), "x", None, &[], vec![], AT);
940        let wrap = seal(&stale, &author);
941        assert!(matches!(open(&wrap), Err(ChatError::Stream(StreamError::EpochMismatch))));
942    }
943
944    #[test]
945    fn reaction_bound_to_channel_a_under_channel_b_key_is_rejected() {
946        // Cross-channel splice: a keyholder of both channels re-seals a rumor
947        // bound to A under B's key — the binding must be judged against the
948        // key that decrypted, never the rumor's own claim.
949        let author = Keys::generate();
950        let chan_a = ChannelId([0xaa; 32]);
951        let chan_b = ChannelId([0xbb; 32]);
952        let group_b = chat_group_key(&secret(), &chan_b, Epoch(0));
953        let rumor = build_reaction_rumor(
954            author.public_key(),
955            &chan_a,
956            Epoch(0),
957            &"bc".repeat(32),
958            &Keys::generate().public_key().to_hex(),
959            kind::MESSAGE,
960            "🔥",
961            None,
962            AT,
963        );
964        let (wrap, _) = seal_chat_rumor(&rumor, &group_b, &author, WRAP_AT, false).unwrap();
965        assert!(matches!(
966            open_chat_event(&wrap, &group_b, &chan_b, Epoch(0)),
967            Err(ChatError::Stream(StreamError::ChannelMismatch))
968        ));
969    }
970
971    #[test]
972    fn multi_epoch_opens_each_wrap_under_its_own_epoch() {
973        let author = Keys::generate();
974        let key0 = [1u8; 32];
975        let key1 = [2u8; 32];
976        let held = [(Epoch(0), key0), (Epoch(1), key1)];
977
978        let m0 = build_message_rumor(author.public_key(), &chan(), Epoch(0), "before the rekey", None, &[], vec![], AT);
979        let g0 = chat_group_key(&key0, &chan(), Epoch(0));
980        let (w0, _) = seal_chat_rumor(&m0, &g0, &author, WRAP_AT, false).unwrap();
981
982        let m1 = build_message_rumor(author.public_key(), &chan(), Epoch(1), "after the rekey", None, &[], vec![], AT + 1);
983        let g1 = chat_group_key(&key1, &chan(), Epoch(1));
984        let (w1, _) = seal_chat_rumor(&m1, &g1, &author, WRAP_AT, false).unwrap();
985
986        let (ev0, e0) = open_chat_event_multi(&w0, &held, &chan()).unwrap();
987        assert_eq!(e0, Epoch(0));
988        assert_eq!(ev0.opened().rumor.content, "before the rekey");
989        let (ev1, e1) = open_chat_event_multi(&w1, &held, &chan()).unwrap();
990        assert_eq!(e1, Epoch(1));
991        assert_eq!(ev1.opened().rumor.content, "after the rekey");
992    }
993
994    #[test]
995    fn multi_epoch_unheld_wrap_is_not_ours() {
996        let author = Keys::generate();
997        let held = [(Epoch(0), [1u8; 32]), (Epoch(1), [2u8; 32])];
998        let m2 = build_message_rumor(author.public_key(), &chan(), Epoch(2), "future", None, &[], vec![], AT);
999        let g2 = chat_group_key(&[3u8; 32], &chan(), Epoch(2));
1000        let (w2, _) = seal_chat_rumor(&m2, &g2, &author, WRAP_AT, false).unwrap();
1001        assert!(matches!(open_chat_event_multi(&w2, &held, &chan()), Err(ChatError::NoHeldEpoch)));
1002    }
1003
1004    #[test]
1005    fn multi_epoch_cross_epoch_splice_is_rejected() {
1006        // A rumor bound to epoch 0 re-sealed under epoch 1's key: selection by
1007        // wrap author picks epoch 1, and the binding must then fail — held-key
1008        // selection can never launder a stale-epoch rumor.
1009        let author = Keys::generate();
1010        let key0 = [1u8; 32];
1011        let key1 = [2u8; 32];
1012        let held = [(Epoch(0), key0), (Epoch(1), key1)];
1013        let stale = build_message_rumor(author.public_key(), &chan(), Epoch(0), "replay", None, &[], vec![], AT);
1014        let g1 = chat_group_key(&key1, &chan(), Epoch(1));
1015        let (wrap, _) = seal_chat_rumor(&stale, &g1, &author, WRAP_AT, false).unwrap();
1016        assert!(matches!(
1017            open_chat_event_multi(&wrap, &held, &chan()),
1018            Err(ChatError::Stream(StreamError::EpochMismatch))
1019        ));
1020    }
1021
1022    #[test]
1023    fn duplicate_e_tag_on_a_reaction_is_rejected() {
1024        let author = Keys::generate();
1025        let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
1026        tags.push(Tag::custom(TagKind::e(), ["aa".repeat(32)]));
1027        tags.push(Tag::custom(TagKind::e(), ["bb".repeat(32)]));
1028        tags.push(Tag::custom(TagKind::p(), [Keys::generate().public_key().to_hex()]));
1029        let rumor = stream::build_rumor_ms(kind::REACTION, author.public_key(), "+", tags, AT);
1030        assert!(matches!(
1031            open(&seal(&rumor, &author)),
1032            Err(ChatError::DuplicateTag(TAG_TARGET))
1033        ));
1034    }
1035
1036    #[test]
1037    fn unknown_rumor_kind_is_rejected_on_both_sides() {
1038        let author = Keys::generate();
1039        // 3300 is a RETIRED v1 number — burned forever, never a v2 chat kind.
1040        let tags = stream::channel_binding_tags(&chan(), Epoch(0));
1041        let rumor = stream::build_rumor_ms(3300, author.public_key(), "v1 ghost", tags, AT);
1042        assert!(matches!(
1043            seal_chat_rumor(&rumor, &group(), &author, WRAP_AT, false),
1044            Err(ChatError::UnknownKind(3300))
1045        ));
1046        // And a wrap hand-built around it (bypassing the send gate) dies on open.
1047        let seal = stream::build_seal(&rumor, SealForm::Encrypted, &group(), &author).unwrap();
1048        let (wrap, _) = stream::wrap_seal(&seal, &group(), stream::KIND_WRAP, WRAP_AT).unwrap();
1049        assert!(matches!(open(&wrap), Err(ChatError::UnknownKind(3300))));
1050    }
1051
1052    #[test]
1053    fn plaintext_sealed_chat_event_is_rejected() {
1054        let author = Keys::generate();
1055        let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), "leaky", None, &[], vec![], AT);
1056        let seal = stream::build_seal(&rumor, SealForm::Plaintext, &group(), &author).unwrap();
1057        let (wrap, _) = stream::wrap_seal(&seal, &group(), stream::KIND_WRAP, WRAP_AT).unwrap();
1058        assert!(matches!(open(&wrap), Err(ChatError::NotEncryptedSealed)));
1059    }
1060
1061    #[test]
1062    fn malformed_targets_are_errors_not_panics() {
1063        let author = Keys::generate();
1064        // Reaction: 64 chars but not hex.
1065        let rumor = build_reaction_rumor(
1066            author.public_key(),
1067            &chan(),
1068            Epoch(0),
1069            &"zz".repeat(32),
1070            &Keys::generate().public_key().to_hex(),
1071            kind::MESSAGE,
1072            "+",
1073            None,
1074            AT,
1075        );
1076        assert!(matches!(open(&seal(&rumor, &author)), Err(ChatError::BadTag(TAG_TARGET))));
1077        // Message: truncated q id.
1078        let rumor = build_message_rumor(
1079            author.public_key(),
1080            &chan(),
1081            Epoch(0),
1082            "x",
1083            Some(("abcd", &Keys::generate().public_key().to_hex())),
1084            &[],
1085            vec![],
1086            AT,
1087        );
1088        assert!(matches!(open(&seal(&rumor, &author)), Err(ChatError::BadTag(TAG_QUOTE))));
1089        // Delete: a k tag that isn't an integer kind.
1090        let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
1091        tags.push(Tag::custom(TagKind::e(), ["cd".repeat(32)]));
1092        tags.push(Tag::custom(
1093            TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::K)),
1094            ["nine".to_string()],
1095        ));
1096        let rumor = stream::build_rumor_ms(kind::DELETE, author.public_key(), "", tags, AT);
1097        assert!(matches!(open(&seal(&rumor, &author)), Err(ChatError::BadTag(TAG_TARGET_KIND))));
1098        // Reaction missing its p target author entirely.
1099        let mut tags = stream::channel_binding_tags(&chan(), Epoch(0));
1100        tags.push(Tag::custom(TagKind::e(), ["cd".repeat(32)]));
1101        let rumor = stream::build_rumor_ms(kind::REACTION, author.public_key(), "+", tags, AT);
1102        assert!(matches!(
1103            open(&seal(&rumor, &author)),
1104            Err(ChatError::MissingTag(TAG_TARGET_AUTHOR))
1105        ));
1106    }
1107}