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