Skip to main content

nula_core/nips/
nip53.rs

1//! [NIP-53] Live Activities.
2//!
3//! Five kinds model a range of live / synchronous experiences:
4//!
5//! | Kind    | Form              | Target use                         |
6//! |---------|-------------------|------------------------------------|
7//! | `30311` | addressable       | Live streaming event               |
8//! | `30312` | addressable       | Meeting space (configuration)      |
9//! | `30313` | addressable       | Meeting room (scheduled / ongoing) |
10//! | `1311`  | regular           | Live chat message                  |
11//! | `10312` | regular-replaceable | Room presence signal            |
12//!
13//! Shared concepts:
14//!
15//! - **[`LiveStatus`]** — the spec's `planned`/`live`/`ended`
16//!   tri-state plus a forward-compatible `Custom(String)` escape hatch
17//!   for producers using extensions (the spec §"Meeting Space" hints at
18//!   `open`/`private`/`closed`, which are exposed through
19//!   [`SpaceStatus`]).
20//! - **[`LiveParticipant`]** — the `p` tag shape (`pubkey`, relay hint,
21//!   role marker, optional proof). Spec §"Proof of Agreement to
22//!   Participate" pins the 5th column: a SHA-256 of the host's `a`
23//!   coordinate signed with each participant's private key.
24//!
25//! Unknown tags round-trip through the per-bundle `extra_tags` vector.
26//!
27//! [NIP-53]: https://github.com/nostr-protocol/nips/blob/master/53.md
28
29use thiserror::Error;
30
31use crate::event::{
32    Alphabet, Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind,
33    SingleLetterTag, Tag, TagKind, Tags,
34};
35use crate::key::{PublicKey, PublicKeyError};
36use crate::types::{RelayUrl, RelayUrlError, Timestamp, TimestampError, Url, UrlError};
37
38/// `kind: 30311` — live streaming event.
39pub const KIND_LIVE_STREAM: Kind = Kind::LIVE_STREAM;
40
41/// `kind: 30312` — meeting space event.
42pub const KIND_MEETING_SPACE: Kind = Kind::MEETING_SPACE;
43
44/// `kind: 30313` — meeting room event.
45pub const KIND_MEETING_ROOM: Kind = Kind::MEETING_ROOM;
46
47/// `kind: 1311` — live chat message.
48pub const KIND_LIVE_CHAT: Kind = Kind::LIVE_CHAT_MESSAGE;
49
50/// `kind: 10312` — room presence signal.
51pub const KIND_ROOM_PRESENCE: Kind = Kind::ROOM_PRESENCE;
52
53const TITLE_TAG: &str = "title";
54const SUMMARY_TAG: &str = "summary";
55const IMAGE_TAG: &str = "image";
56const STREAMING_TAG: &str = "streaming";
57const RECORDING_TAG: &str = "recording";
58const STARTS_TAG: &str = "starts";
59const ENDS_TAG: &str = "ends";
60const STATUS_TAG: &str = "status";
61const CURRENT_PARTICIPANTS_TAG: &str = "current_participants";
62const TOTAL_PARTICIPANTS_TAG: &str = "total_participants";
63const PINNED_TAG: &str = "pinned";
64const RELAYS_TAG: &str = "relays";
65const ROOM_TAG: &str = "room";
66const SERVICE_TAG: &str = "service";
67const ENDPOINT_TAG: &str = "endpoint";
68const HAND_TAG: &str = "hand";
69
70/// Spec-defined wire tokens for the live-event `status` column
71/// (`30311` / `30313`).
72#[derive(Debug, Clone, PartialEq, Eq, Hash)]
73pub enum LiveStatus {
74    /// `planned`.
75    Planned,
76    /// `live`.
77    Live,
78    /// `ended`.
79    Ended,
80    /// Forward-compatible passthrough for unknown tokens.
81    Custom(String),
82}
83
84impl LiveStatus {
85    /// Wire token.
86    #[must_use]
87    #[expect(
88        clippy::missing_const_for_fn,
89        reason = "`Self::Custom` borrows from a heap `String`"
90    )]
91    pub fn as_str(&self) -> &str {
92        match self {
93            Self::Planned => "planned",
94            Self::Live => "live",
95            Self::Ended => "ended",
96            Self::Custom(s) => s.as_str(),
97        }
98    }
99
100    /// Parse a wire token. Always succeeds: unknown tokens decode
101    /// as [`Self::Custom`].
102    #[must_use]
103    pub fn parse(token: &str) -> Self {
104        match token {
105            "planned" => Self::Planned,
106            "live" => Self::Live,
107            "ended" => Self::Ended,
108            _ => Self::Custom(token.to_owned()),
109        }
110    }
111}
112
113/// Spec-defined wire tokens for the meeting-space `status` column
114/// (`30312`).
115#[derive(Debug, Clone, PartialEq, Eq, Hash)]
116pub enum SpaceStatus {
117    /// `open` — the space is accepting participants.
118    Open,
119    /// `private` — the space is access-controlled.
120    Private,
121    /// `closed` — the space is not in operation.
122    Closed,
123    /// Forward-compatible passthrough for unknown tokens.
124    Custom(String),
125}
126
127impl SpaceStatus {
128    /// Wire token.
129    #[must_use]
130    #[expect(
131        clippy::missing_const_for_fn,
132        reason = "`Self::Custom` borrows from a heap `String`"
133    )]
134    pub fn as_str(&self) -> &str {
135        match self {
136            Self::Open => "open",
137            Self::Private => "private",
138            Self::Closed => "closed",
139            Self::Custom(s) => s.as_str(),
140        }
141    }
142
143    /// Parse a wire token.
144    #[must_use]
145    pub fn parse(token: &str) -> Self {
146        match token {
147            "open" => Self::Open,
148            "private" => Self::Private,
149            "closed" => Self::Closed,
150            _ => Self::Custom(token.to_owned()),
151        }
152    }
153}
154
155/// A `p` participant tag on a live / meeting event. Model shared by
156/// all four host-side kinds (`30311`, `30312`, `30313`) and the
157/// `10312` presence row.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct LiveParticipant {
160    /// Participant pubkey.
161    pub pubkey: PublicKey,
162    /// Optional recommended relay URL.
163    pub relay_hint: Option<RelayUrl>,
164    /// Display role marker (`Host`, `Speaker`, `Moderator`, custom).
165    pub role: Option<String>,
166    /// Optional proof column: SHA-256 of the host event's
167    /// addressable coordinate signed with the participant's private
168    /// key (spec §"Proof of Agreement to Participate").
169    pub proof: Option<String>,
170}
171
172impl LiveParticipant {
173    /// Construct a participant with no relay hint, role, or proof.
174    #[must_use]
175    pub const fn new(pubkey: PublicKey) -> Self {
176        Self {
177            pubkey,
178            relay_hint: None,
179            role: None,
180            proof: None,
181        }
182    }
183
184    /// Attach a relay hint.
185    #[must_use]
186    pub fn relay_hint(mut self, relay: RelayUrl) -> Self {
187        self.relay_hint = Some(relay);
188        self
189    }
190
191    /// Attach a display role.
192    #[must_use]
193    pub fn role(mut self, role: impl Into<String>) -> Self {
194        self.role = Some(role.into());
195        self
196    }
197
198    /// Attach a participation proof.
199    #[must_use]
200    pub fn proof(mut self, proof: impl Into<String>) -> Self {
201        self.proof = Some(proof.into());
202        self
203    }
204
205    /// Render as a `p` tag.
206    #[must_use]
207    pub fn to_tag(&self) -> Tag {
208        let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
209        let relay = self
210            .relay_hint
211            .as_ref()
212            .map_or_else(String::new, |r| r.as_str().to_owned());
213        match (&self.role, &self.proof) {
214            (Some(role), Some(proof)) => Tag::with(
215                &head,
216                [self.pubkey.to_hex(), relay, role.clone(), proof.clone()],
217            ),
218            (Some(role), None) => Tag::with(&head, [self.pubkey.to_hex(), relay, role.clone()]),
219            (None, _) if self.relay_hint.is_some() => {
220                Tag::with(&head, [self.pubkey.to_hex(), relay])
221            }
222            _ => Tag::with(&head, [self.pubkey.to_hex()]),
223        }
224    }
225
226    /// Parse a `p` tag.
227    ///
228    /// # Errors
229    ///
230    /// - [`LiveError::MalformedParticipant`] when column 1 is
231    ///   absent.
232    /// - Wrapped [`PublicKeyError`] / [`RelayUrlError`] for invalid
233    ///   values.
234    pub fn from_tag(tag: &Tag) -> Result<Self, LiveError> {
235        let pk_hex = tag.get(1).ok_or(LiveError::MalformedParticipant)?;
236        let pubkey = PublicKey::parse(pk_hex)?;
237        let relay_hint = match tag.get(2) {
238            Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
239            _ => None,
240        };
241        let role = tag.get(3).filter(|s| !s.is_empty()).map(str::to_owned);
242        let proof = tag.get(4).filter(|s| !s.is_empty()).map(str::to_owned);
243        Ok(Self {
244            pubkey,
245            relay_hint,
246            role,
247            proof,
248        })
249    }
250}
251
252/// Typed bundle for a `kind: 30311` live streaming event.
253#[derive(Debug, Clone, PartialEq, Eq, Default)]
254pub struct LiveStream {
255    /// `d` identifier.
256    pub identifier: String,
257    /// `title`.
258    pub title: Option<String>,
259    /// `summary`.
260    pub summary: Option<String>,
261    /// `image` preview URL.
262    pub image: Option<Url>,
263    /// `streaming` URL.
264    pub streaming_url: Option<Url>,
265    /// `recording` URL (posted once the activity ends).
266    pub recording_url: Option<Url>,
267    /// `starts` Unix timestamp.
268    pub starts: Option<Timestamp>,
269    /// `ends` Unix timestamp.
270    pub ends: Option<Timestamp>,
271    /// `status`.
272    pub status: Option<LiveStatus>,
273    /// `current_participants` count.
274    pub current_participants: Option<u64>,
275    /// `total_participants` count.
276    pub total_participants: Option<u64>,
277    /// `p` participants.
278    pub participants: Vec<LiveParticipant>,
279    /// `t` hashtags (lower-cased).
280    pub hashtags: Vec<String>,
281    /// `relays` recommendations.
282    pub relays: Vec<RelayUrl>,
283    /// `pinned` live-chat message event ids.
284    pub pinned: Vec<EventId>,
285    /// Forward-compatible passthrough for unknown tags.
286    pub extra_tags: Vec<Tag>,
287}
288
289/// Typed bundle for a `kind: 30312` meeting space event.
290#[derive(Debug, Clone, PartialEq, Eq, Default)]
291pub struct MeetingSpace {
292    /// `d` identifier.
293    pub identifier: String,
294    /// `room` display name (required).
295    pub room: Option<String>,
296    /// `summary`.
297    pub summary: Option<String>,
298    /// `image` preview URL.
299    pub image: Option<Url>,
300    /// `status` (`open`/`private`/`closed`).
301    pub status: Option<SpaceStatus>,
302    /// `service` URL (required per spec).
303    pub service_url: Option<Url>,
304    /// Optional `endpoint` URL.
305    pub endpoint_url: Option<Url>,
306    /// `t` hashtags (lower-cased).
307    pub hashtags: Vec<String>,
308    /// `p` participants (at least one MUST hold `Host` role).
309    pub participants: Vec<LiveParticipant>,
310    /// `relays` recommendations.
311    pub relays: Vec<RelayUrl>,
312    /// Forward-compatible passthrough for unknown tags.
313    pub extra_tags: Vec<Tag>,
314}
315
316/// Typed bundle for a `kind: 30313` meeting room event (scheduled
317/// session within a space).
318#[derive(Debug, Clone, PartialEq, Eq, Default)]
319pub struct MeetingRoom {
320    /// `d` identifier.
321    pub identifier: String,
322    /// Parent `30312` space coordinate (required `a` tag).
323    pub space: Option<Coordinate>,
324    /// Optional relay hint for the parent space.
325    pub space_relay_hint: Option<RelayUrl>,
326    /// `title` (required).
327    pub title: Option<String>,
328    /// `summary`.
329    pub summary: Option<String>,
330    /// `image` preview URL.
331    pub image: Option<Url>,
332    /// `starts` Unix timestamp (required).
333    pub starts: Option<Timestamp>,
334    /// `ends` Unix timestamp.
335    pub ends: Option<Timestamp>,
336    /// `status` (required).
337    pub status: Option<LiveStatus>,
338    /// `total_participants` count.
339    pub total_participants: Option<u64>,
340    /// `current_participants` count.
341    pub current_participants: Option<u64>,
342    /// `p` participants.
343    pub participants: Vec<LiveParticipant>,
344    /// Forward-compatible passthrough for unknown tags.
345    pub extra_tags: Vec<Tag>,
346}
347
348/// Typed bundle for a `kind: 1311` live chat message.
349#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct LiveChatMessage {
351    /// `.content` body.
352    pub content: String,
353    /// Required `a` tag pointing at the host event (30311 / 30313 /
354    /// 30312).
355    pub host: Coordinate,
356    /// Optional relay hint for the host coordinate.
357    pub host_relay_hint: Option<RelayUrl>,
358    /// Optional thread-marker on the host `a` tag (`root`, etc).
359    pub host_marker: Option<String>,
360    /// Optional `e` tag pointing at the parent chat message.
361    pub parent_id: Option<EventId>,
362    /// Optional relay hint for [`Self::parent_id`].
363    pub parent_id_relay_hint: Option<RelayUrl>,
364    /// Optional `q` tag (NIP-21 citation).
365    pub quote_id: Option<EventId>,
366    /// Optional relay hint for [`Self::quote_id`].
367    pub quote_id_relay_hint: Option<RelayUrl>,
368    /// Optional quoted-event author pubkey (4th column of `q`).
369    pub quote_author: Option<PublicKey>,
370    /// Forward-compatible passthrough for unknown tags.
371    pub extra_tags: Vec<Tag>,
372}
373
374/// Typed bundle for a `kind: 10312` room presence signal.
375#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct RoomPresence {
377    /// Required `a` tag referencing the room/space.
378    pub room: Coordinate,
379    /// Optional relay hint.
380    pub room_relay_hint: Option<RelayUrl>,
381    /// Optional thread-marker on the `a` tag (`root`, etc).
382    pub room_marker: Option<String>,
383    /// Whether the `hand` flag is raised (spec example).
384    pub hand_raised: bool,
385    /// Forward-compatible passthrough for unknown tags.
386    pub extra_tags: Vec<Tag>,
387}
388
389/// Errors raised by NIP-53 parsers.
390#[derive(Debug, Error)]
391#[non_exhaustive]
392pub enum LiveError {
393    /// Event kind is not one of the NIP-53 kinds.
394    #[error("unexpected kind for NIP-53 event: {}", .0.as_u16())]
395    WrongKind(Kind),
396    /// `d` tag is absent on an addressable kind.
397    #[error("NIP-53 event missing `d` tag")]
398    MissingIdentifier,
399    /// `a` tag is absent on a kind that requires it
400    /// (`1311`, `10312`, `30313`).
401    #[error("NIP-53 event missing required `a` tag")]
402    MissingAddress,
403    /// `p` tag is missing the pubkey column.
404    #[error("`p` participant tag missing pubkey")]
405    MalformedParticipant,
406    /// Numeric tag (`current_participants`, `total_participants`,
407    /// `starts`, `ends`) failed to parse.
408    #[error("invalid numeric tag `{tag}` value `{value}`")]
409    InvalidNumber {
410        /// Name of the offending tag.
411        tag: String,
412        /// Raw string value that failed to parse.
413        value: String,
414    },
415    /// Wrapped pubkey parser error.
416    #[error(transparent)]
417    InvalidPublicKey(#[from] PublicKeyError),
418    /// Wrapped relay-URL parser error.
419    #[error(transparent)]
420    InvalidRelayUrl(#[from] RelayUrlError),
421    /// Wrapped URL parser error.
422    #[error(transparent)]
423    InvalidUrl(#[from] UrlError),
424    /// Wrapped timestamp parser error.
425    #[error(transparent)]
426    InvalidTimestamp(#[from] TimestampError),
427    /// Wrapped event-id parser error.
428    #[error(transparent)]
429    InvalidEventId(#[from] EventIdError),
430    /// Wrapped coordinate parser error.
431    #[error(transparent)]
432    InvalidCoordinate(#[from] CoordinateError),
433}
434
435fn parse_u64(tag: &str, value: &str) -> Result<u64, LiveError> {
436    value.parse::<u64>().map_err(|_| LiveError::InvalidNumber {
437        tag: tag.to_owned(),
438        value: value.to_owned(),
439    })
440}
441
442fn parse_relay_hint(raw: Option<&str>) -> Result<Option<RelayUrl>, RelayUrlError> {
443    match raw {
444        Some(s) if !s.is_empty() => RelayUrl::parse(s).map(Some),
445        _ => Ok(None),
446    }
447}
448
449fn parse_coordinate_tag(
450    tag: &Tag,
451) -> Result<(Coordinate, Option<RelayUrl>, Option<String>), LiveError> {
452    let coord_str = tag.get(1).ok_or(LiveError::MissingAddress)?;
453    let coordinate = Coordinate::parse(coord_str)?;
454    let relay_hint = parse_relay_hint(tag.get(2))?;
455    let marker = tag.get(3).filter(|s| !s.is_empty()).map(str::to_owned);
456    Ok((coordinate, relay_hint, marker))
457}
458
459fn parse_event_ref(tag: &Tag) -> Result<(EventId, Option<RelayUrl>), LiveError> {
460    let id_hex = tag.get(1).ok_or(LiveError::MissingAddress)?;
461    let id = EventId::parse(id_hex)?;
462    let relay_hint = parse_relay_hint(tag.get(2))?;
463    Ok((id, relay_hint))
464}
465
466fn coordinate_tag(coord: &Coordinate, relay: Option<&RelayUrl>, marker: Option<&str>) -> Tag {
467    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::A));
468    let relay_str = relay.map_or_else(String::new, |r| r.as_str().to_owned());
469    match marker {
470        Some(m) => Tag::with(&head, [coord.to_wire(), relay_str, m.to_owned()]),
471        None if relay.is_some() => Tag::with(&head, [coord.to_wire(), relay_str]),
472        None => Tag::with(&head, [coord.to_wire()]),
473    }
474}
475
476fn event_ref_tag(id: EventId, relay: Option<&RelayUrl>) -> Tag {
477    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
478    relay.map_or_else(
479        || Tag::with(&head, [id.to_hex()]),
480        |r| Tag::with(&head, [id.to_hex(), r.as_str().to_owned()]),
481    )
482}
483
484fn d_value(tags: &Tags) -> Option<&str> {
485    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
486    tags.find_first(&head).and_then(|tag| tag.get(1))
487}
488
489impl LiveStream {
490    /// Construct a stream with the identifier seeded.
491    #[must_use]
492    pub fn new(identifier: impl Into<String>) -> Self {
493        Self {
494            identifier: identifier.into(),
495            ..Self::default()
496        }
497    }
498
499    /// Build the stream's addressable coordinate.
500    #[must_use]
501    pub fn coordinate(&self, author: PublicKey) -> Coordinate {
502        Coordinate::new(KIND_LIVE_STREAM, author, self.identifier.clone())
503    }
504
505    /// Parse a `kind: 30311` event.
506    ///
507    /// # Errors
508    ///
509    /// See [`LiveError`] for the failure modes.
510    pub fn from_event(event: &Event) -> Result<Self, LiveError> {
511        if event.kind != KIND_LIVE_STREAM {
512            return Err(LiveError::WrongKind(event.kind));
513        }
514        let identifier = d_value(&event.tags)
515            .ok_or(LiveError::MissingIdentifier)?
516            .to_owned();
517        let mut out = Self::new(identifier);
518        for tag in &event.tags {
519            absorb_live_stream_tag(tag, &mut out)?;
520        }
521        Ok(out)
522    }
523}
524
525fn absorb_live_stream_tag(tag: &Tag, out: &mut LiveStream) -> Result<(), LiveError> {
526    if absorb_live_stream_single_letter(tag, out)? {
527        return Ok(());
528    }
529    let col1 = tag.get(1);
530    match tag.name() {
531        TITLE_TAG => out.title = col1.map(str::to_owned),
532        SUMMARY_TAG => out.summary = col1.map(str::to_owned),
533        IMAGE_TAG => absorb_optional_url(col1, &mut out.image)?,
534        STREAMING_TAG => absorb_optional_url(col1, &mut out.streaming_url)?,
535        RECORDING_TAG => absorb_optional_url(col1, &mut out.recording_url)?,
536        STARTS_TAG => absorb_optional_timestamp(col1, &mut out.starts)?,
537        ENDS_TAG => absorb_optional_timestamp(col1, &mut out.ends)?,
538        STATUS_TAG => out.status = col1.map(LiveStatus::parse),
539        CURRENT_PARTICIPANTS_TAG => {
540            absorb_optional_u64(
541                CURRENT_PARTICIPANTS_TAG,
542                col1,
543                &mut out.current_participants,
544            )?;
545        }
546        TOTAL_PARTICIPANTS_TAG => {
547            absorb_optional_u64(TOTAL_PARTICIPANTS_TAG, col1, &mut out.total_participants)?;
548        }
549        PINNED_TAG => {
550            if let Some(raw) = col1 {
551                out.pinned.push(EventId::parse(raw)?);
552            }
553        }
554        RELAYS_TAG => absorb_relay_list(tag, &mut out.relays)?,
555        _ => out.extra_tags.push(tag.clone()),
556    }
557    Ok(())
558}
559
560fn absorb_live_stream_single_letter(tag: &Tag, out: &mut LiveStream) -> Result<bool, LiveError> {
561    match tag.kind() {
562        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => Ok(true),
563        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P => {
564            out.participants.push(LiveParticipant::from_tag(tag)?);
565            Ok(true)
566        }
567        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::T => {
568            if let Some(raw) = tag.get(1) {
569                out.hashtags.push(raw.to_ascii_lowercase());
570            }
571            Ok(true)
572        }
573        _ => Ok(false),
574    }
575}
576
577fn absorb_optional_url(col1: Option<&str>, slot: &mut Option<Url>) -> Result<(), LiveError> {
578    if let Some(raw) = col1 {
579        *slot = Some(Url::parse(raw)?);
580    }
581    Ok(())
582}
583
584fn absorb_optional_timestamp(
585    col1: Option<&str>,
586    slot: &mut Option<Timestamp>,
587) -> Result<(), LiveError> {
588    if let Some(raw) = col1 {
589        *slot = Some(raw.parse::<Timestamp>()?);
590    }
591    Ok(())
592}
593
594fn absorb_optional_u64(
595    name: &str,
596    col1: Option<&str>,
597    slot: &mut Option<u64>,
598) -> Result<(), LiveError> {
599    if let Some(raw) = col1 {
600        *slot = Some(parse_u64(name, raw)?);
601    }
602    Ok(())
603}
604
605fn absorb_relay_list(tag: &Tag, relays: &mut Vec<RelayUrl>) -> Result<(), LiveError> {
606    for raw in tag.values().iter().skip(1) {
607        relays.push(RelayUrl::parse(raw)?);
608    }
609    Ok(())
610}
611
612impl MeetingSpace {
613    /// Construct a space with the identifier seeded.
614    #[must_use]
615    pub fn new(identifier: impl Into<String>) -> Self {
616        Self {
617            identifier: identifier.into(),
618            ..Self::default()
619        }
620    }
621
622    /// Build the space's addressable coordinate.
623    #[must_use]
624    pub fn coordinate(&self, author: PublicKey) -> Coordinate {
625        Coordinate::new(KIND_MEETING_SPACE, author, self.identifier.clone())
626    }
627
628    /// Parse a `kind: 30312` event.
629    ///
630    /// # Errors
631    ///
632    /// See [`LiveError`] for the failure modes.
633    pub fn from_event(event: &Event) -> Result<Self, LiveError> {
634        if event.kind != KIND_MEETING_SPACE {
635            return Err(LiveError::WrongKind(event.kind));
636        }
637        let identifier = d_value(&event.tags)
638            .ok_or(LiveError::MissingIdentifier)?
639            .to_owned();
640        let mut out = Self::new(identifier);
641        for tag in &event.tags {
642            absorb_meeting_space_tag(tag, &mut out)?;
643        }
644        Ok(out)
645    }
646}
647
648fn absorb_meeting_space_tag(tag: &Tag, out: &mut MeetingSpace) -> Result<(), LiveError> {
649    match tag.kind() {
650        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
651        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P => {
652            out.participants.push(LiveParticipant::from_tag(tag)?);
653        }
654        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::T => {
655            if let Some(raw) = tag.get(1) {
656                out.hashtags.push(raw.to_ascii_lowercase());
657            }
658        }
659        _ => absorb_meeting_space_named_tag(tag, out)?,
660    }
661    Ok(())
662}
663
664fn absorb_meeting_space_named_tag(tag: &Tag, out: &mut MeetingSpace) -> Result<(), LiveError> {
665    let col1 = tag.get(1);
666    match tag.name() {
667        ROOM_TAG => out.room = col1.map(str::to_owned),
668        SUMMARY_TAG => out.summary = col1.map(str::to_owned),
669        IMAGE_TAG => absorb_optional_url(col1, &mut out.image)?,
670        STATUS_TAG => out.status = col1.map(SpaceStatus::parse),
671        SERVICE_TAG => absorb_optional_url(col1, &mut out.service_url)?,
672        ENDPOINT_TAG => absorb_optional_url(col1, &mut out.endpoint_url)?,
673        RELAYS_TAG => absorb_relay_list(tag, &mut out.relays)?,
674        _ => out.extra_tags.push(tag.clone()),
675    }
676    Ok(())
677}
678
679impl MeetingRoom {
680    /// Construct a room with the identifier seeded.
681    #[must_use]
682    pub fn new(identifier: impl Into<String>) -> Self {
683        Self {
684            identifier: identifier.into(),
685            ..Self::default()
686        }
687    }
688
689    /// Build the room's addressable coordinate.
690    #[must_use]
691    pub fn coordinate(&self, author: PublicKey) -> Coordinate {
692        Coordinate::new(KIND_MEETING_ROOM, author, self.identifier.clone())
693    }
694
695    /// Parse a `kind: 30313` event.
696    ///
697    /// # Errors
698    ///
699    /// See [`LiveError`] for the failure modes.
700    pub fn from_event(event: &Event) -> Result<Self, LiveError> {
701        if event.kind != KIND_MEETING_ROOM {
702            return Err(LiveError::WrongKind(event.kind));
703        }
704        let identifier = d_value(&event.tags)
705            .ok_or(LiveError::MissingIdentifier)?
706            .to_owned();
707        let mut out = Self::new(identifier);
708        for tag in &event.tags {
709            absorb_meeting_room_tag(tag, &mut out)?;
710        }
711        if out.space.is_none() {
712            return Err(LiveError::MissingAddress);
713        }
714        Ok(out)
715    }
716}
717
718fn absorb_meeting_room_tag(tag: &Tag, out: &mut MeetingRoom) -> Result<(), LiveError> {
719    match tag.kind() {
720        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
721        TagKind::SingleLetter(s)
722            if !s.uppercase && s.character == Alphabet::A && out.space.is_none() =>
723        {
724            let (coord, relay, _) = parse_coordinate_tag(tag)?;
725            out.space = Some(coord);
726            out.space_relay_hint = relay;
727        }
728        TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P => {
729            out.participants.push(LiveParticipant::from_tag(tag)?);
730        }
731        _ => absorb_meeting_room_named_tag(tag, out)?,
732    }
733    Ok(())
734}
735
736fn absorb_meeting_room_named_tag(tag: &Tag, out: &mut MeetingRoom) -> Result<(), LiveError> {
737    let col1 = tag.get(1);
738    match tag.name() {
739        TITLE_TAG => out.title = col1.map(str::to_owned),
740        SUMMARY_TAG => out.summary = col1.map(str::to_owned),
741        IMAGE_TAG => absorb_optional_url(col1, &mut out.image)?,
742        STARTS_TAG => absorb_optional_timestamp(col1, &mut out.starts)?,
743        ENDS_TAG => absorb_optional_timestamp(col1, &mut out.ends)?,
744        STATUS_TAG => out.status = col1.map(LiveStatus::parse),
745        CURRENT_PARTICIPANTS_TAG => {
746            absorb_optional_u64(
747                CURRENT_PARTICIPANTS_TAG,
748                col1,
749                &mut out.current_participants,
750            )?;
751        }
752        TOTAL_PARTICIPANTS_TAG => {
753            absorb_optional_u64(TOTAL_PARTICIPANTS_TAG, col1, &mut out.total_participants)?;
754        }
755        _ => out.extra_tags.push(tag.clone()),
756    }
757    Ok(())
758}
759
760impl LiveChatMessage {
761    /// Construct a chat message targeted at `host`.
762    #[must_use]
763    pub fn new(content: impl Into<String>, host: Coordinate) -> Self {
764        Self {
765            content: content.into(),
766            host,
767            host_relay_hint: None,
768            host_marker: Some("root".to_owned()),
769            parent_id: None,
770            parent_id_relay_hint: None,
771            quote_id: None,
772            quote_id_relay_hint: None,
773            quote_author: None,
774            extra_tags: Vec::new(),
775        }
776    }
777
778    /// Parse a `kind: 1311` event.
779    ///
780    /// # Errors
781    ///
782    /// See [`LiveError`] for the failure modes.
783    pub fn from_event(event: &Event) -> Result<Self, LiveError> {
784        if event.kind != KIND_LIVE_CHAT {
785            return Err(LiveError::WrongKind(event.kind));
786        }
787        let mut host: Option<(Coordinate, Option<RelayUrl>, Option<String>)> = None;
788        let mut parent: Option<(EventId, Option<RelayUrl>)> = None;
789        let mut quote: Option<(EventId, Option<RelayUrl>, Option<PublicKey>)> = None;
790        let mut extra_tags: Vec<Tag> = Vec::new();
791        for tag in &event.tags {
792            match tag.kind() {
793                TagKind::SingleLetter(s)
794                    if !s.uppercase && s.character == Alphabet::A && host.is_none() =>
795                {
796                    host = Some(parse_coordinate_tag(tag)?);
797                }
798                TagKind::SingleLetter(s)
799                    if !s.uppercase && s.character == Alphabet::E && parent.is_none() =>
800                {
801                    parent = Some(parse_event_ref(tag)?);
802                }
803                TagKind::SingleLetter(s)
804                    if !s.uppercase && s.character == Alphabet::Q && quote.is_none() =>
805                {
806                    quote = Some(parse_quote_tag(tag)?);
807                }
808                _ => extra_tags.push(tag.clone()),
809            }
810        }
811        let (host_coord, host_relay_hint, host_marker) = host.ok_or(LiveError::MissingAddress)?;
812        Ok(Self {
813            content: event.content.clone(),
814            host: host_coord,
815            host_relay_hint,
816            host_marker,
817            parent_id: parent.as_ref().map(|(id, _)| *id),
818            parent_id_relay_hint: parent.and_then(|(_, relay)| relay),
819            quote_id: quote.as_ref().map(|(id, _, _)| *id),
820            quote_id_relay_hint: quote.as_ref().and_then(|(_, relay, _)| relay.clone()),
821            quote_author: quote.and_then(|(_, _, pk)| pk),
822            extra_tags,
823        })
824    }
825}
826
827fn parse_quote_tag(tag: &Tag) -> Result<(EventId, Option<RelayUrl>, Option<PublicKey>), LiveError> {
828    let id_hex = tag.get(1).ok_or(LiveError::MissingAddress)?;
829    let id = EventId::parse(id_hex)?;
830    let relay_hint = parse_relay_hint(tag.get(2))?;
831    let pk = match tag.get(3) {
832        Some(s) if !s.is_empty() => Some(PublicKey::parse(s)?),
833        _ => None,
834    };
835    Ok((id, relay_hint, pk))
836}
837
838impl RoomPresence {
839    /// Construct a presence row targeted at `room`.
840    #[must_use]
841    pub fn new(room: Coordinate) -> Self {
842        Self {
843            room,
844            room_relay_hint: None,
845            room_marker: Some("root".to_owned()),
846            hand_raised: false,
847            extra_tags: Vec::new(),
848        }
849    }
850
851    /// Set [`Self::hand_raised`].
852    #[must_use]
853    pub const fn hand_raised(mut self, raised: bool) -> Self {
854        self.hand_raised = raised;
855        self
856    }
857
858    /// Parse a `kind: 10312` event.
859    ///
860    /// # Errors
861    ///
862    /// See [`LiveError`] for the failure modes.
863    pub fn from_event(event: &Event) -> Result<Self, LiveError> {
864        if event.kind != KIND_ROOM_PRESENCE {
865            return Err(LiveError::WrongKind(event.kind));
866        }
867        let mut room: Option<(Coordinate, Option<RelayUrl>, Option<String>)> = None;
868        let mut hand_raised = false;
869        let mut extra_tags: Vec<Tag> = Vec::new();
870        for tag in &event.tags {
871            match tag.kind() {
872                TagKind::SingleLetter(s)
873                    if !s.uppercase && s.character == Alphabet::A && room.is_none() =>
874                {
875                    room = Some(parse_coordinate_tag(tag)?);
876                }
877                _ if tag.name() == HAND_TAG => {
878                    hand_raised = tag.get(1).is_some_and(|v| v == "1");
879                }
880                _ => extra_tags.push(tag.clone()),
881            }
882        }
883        let (room_coord, room_relay_hint, room_marker) = room.ok_or(LiveError::MissingAddress)?;
884        Ok(Self {
885            room: room_coord,
886            room_relay_hint,
887            room_marker,
888            hand_raised,
889            extra_tags,
890        })
891    }
892}
893
894impl EventBuilder {
895    /// Author a NIP-53 `kind: 30311` live streaming event.
896    #[must_use]
897    pub fn live_stream(stream: &LiveStream) -> Self {
898        let mut builder = Self::new(KIND_LIVE_STREAM, "");
899        builder = builder.tag(Tag::d(&stream.identifier));
900        builder = push_option_text_tag(builder, TITLE_TAG, stream.title.as_deref());
901        builder = push_option_text_tag(builder, SUMMARY_TAG, stream.summary.as_deref());
902        builder = push_option_url_tag(builder, IMAGE_TAG, stream.image.as_ref());
903        builder = push_option_url_tag(builder, STREAMING_TAG, stream.streaming_url.as_ref());
904        builder = push_option_url_tag(builder, RECORDING_TAG, stream.recording_url.as_ref());
905        if let Some(ts) = stream.starts {
906            builder = builder.tag(Tag::with(
907                &TagKind::from_wire(STARTS_TAG),
908                [ts.as_secs().to_string()],
909            ));
910        }
911        if let Some(ts) = stream.ends {
912            builder = builder.tag(Tag::with(
913                &TagKind::from_wire(ENDS_TAG),
914                [ts.as_secs().to_string()],
915            ));
916        }
917        if let Some(status) = &stream.status {
918            builder = builder.tag(Tag::with(
919                &TagKind::from_wire(STATUS_TAG),
920                [status.as_str().to_owned()],
921            ));
922        }
923        if let Some(n) = stream.current_participants {
924            builder = builder.tag(Tag::with(
925                &TagKind::from_wire(CURRENT_PARTICIPANTS_TAG),
926                [n.to_string()],
927            ));
928        }
929        if let Some(n) = stream.total_participants {
930            builder = builder.tag(Tag::with(
931                &TagKind::from_wire(TOTAL_PARTICIPANTS_TAG),
932                [n.to_string()],
933            ));
934        }
935        for participant in &stream.participants {
936            builder = builder.tag(participant.to_tag());
937        }
938        for hashtag in &stream.hashtags {
939            builder = builder.tag(Tag::t(hashtag));
940        }
941        for id in &stream.pinned {
942            builder = builder.tag(Tag::with(&TagKind::from_wire(PINNED_TAG), [id.to_hex()]));
943        }
944        if !stream.relays.is_empty() {
945            builder = builder.tag(relays_tag(&stream.relays));
946        }
947        for tag in &stream.extra_tags {
948            builder = builder.tag(tag.clone());
949        }
950        builder
951    }
952
953    /// Author a NIP-53 `kind: 30312` meeting space event.
954    #[must_use]
955    pub fn meeting_space(space: &MeetingSpace) -> Self {
956        let mut builder = Self::new(KIND_MEETING_SPACE, "");
957        builder = builder.tag(Tag::d(&space.identifier));
958        builder = push_option_text_tag(builder, ROOM_TAG, space.room.as_deref());
959        builder = push_option_text_tag(builder, SUMMARY_TAG, space.summary.as_deref());
960        builder = push_option_url_tag(builder, IMAGE_TAG, space.image.as_ref());
961        if let Some(status) = &space.status {
962            builder = builder.tag(Tag::with(
963                &TagKind::from_wire(STATUS_TAG),
964                [status.as_str().to_owned()],
965            ));
966        }
967        builder = push_option_url_tag(builder, SERVICE_TAG, space.service_url.as_ref());
968        builder = push_option_url_tag(builder, ENDPOINT_TAG, space.endpoint_url.as_ref());
969        for hashtag in &space.hashtags {
970            builder = builder.tag(Tag::t(hashtag));
971        }
972        for participant in &space.participants {
973            builder = builder.tag(participant.to_tag());
974        }
975        if !space.relays.is_empty() {
976            builder = builder.tag(relays_tag(&space.relays));
977        }
978        for tag in &space.extra_tags {
979            builder = builder.tag(tag.clone());
980        }
981        builder
982    }
983
984    /// Author a NIP-53 `kind: 30313` meeting room event.
985    ///
986    /// # Errors
987    ///
988    /// Returns [`LiveError::MissingAddress`] when
989    /// [`MeetingRoom::space`] is `None`.
990    pub fn meeting_room(room: &MeetingRoom) -> Result<Self, LiveError> {
991        let space = room.space.as_ref().ok_or(LiveError::MissingAddress)?;
992        let mut builder = Self::new(KIND_MEETING_ROOM, "");
993        builder = builder.tag(Tag::d(&room.identifier)).tag(coordinate_tag(
994            space,
995            room.space_relay_hint.as_ref(),
996            None,
997        ));
998        builder = push_option_text_tag(builder, TITLE_TAG, room.title.as_deref());
999        builder = push_option_text_tag(builder, SUMMARY_TAG, room.summary.as_deref());
1000        builder = push_option_url_tag(builder, IMAGE_TAG, room.image.as_ref());
1001        if let Some(ts) = room.starts {
1002            builder = builder.tag(Tag::with(
1003                &TagKind::from_wire(STARTS_TAG),
1004                [ts.as_secs().to_string()],
1005            ));
1006        }
1007        if let Some(ts) = room.ends {
1008            builder = builder.tag(Tag::with(
1009                &TagKind::from_wire(ENDS_TAG),
1010                [ts.as_secs().to_string()],
1011            ));
1012        }
1013        if let Some(status) = &room.status {
1014            builder = builder.tag(Tag::with(
1015                &TagKind::from_wire(STATUS_TAG),
1016                [status.as_str().to_owned()],
1017            ));
1018        }
1019        if let Some(n) = room.total_participants {
1020            builder = builder.tag(Tag::with(
1021                &TagKind::from_wire(TOTAL_PARTICIPANTS_TAG),
1022                [n.to_string()],
1023            ));
1024        }
1025        if let Some(n) = room.current_participants {
1026            builder = builder.tag(Tag::with(
1027                &TagKind::from_wire(CURRENT_PARTICIPANTS_TAG),
1028                [n.to_string()],
1029            ));
1030        }
1031        for participant in &room.participants {
1032            builder = builder.tag(participant.to_tag());
1033        }
1034        for tag in &room.extra_tags {
1035            builder = builder.tag(tag.clone());
1036        }
1037        Ok(builder)
1038    }
1039
1040    /// Author a NIP-53 `kind: 1311` live chat message.
1041    #[must_use]
1042    pub fn live_chat_message(msg: &LiveChatMessage) -> Self {
1043        let mut builder = Self::new(KIND_LIVE_CHAT, msg.content.clone());
1044        builder = builder.tag(coordinate_tag(
1045            &msg.host,
1046            msg.host_relay_hint.as_ref(),
1047            msg.host_marker.as_deref(),
1048        ));
1049        if let Some(id) = msg.parent_id {
1050            builder = builder.tag(event_ref_tag(id, msg.parent_id_relay_hint.as_ref()));
1051        }
1052        if let Some(id) = msg.quote_id {
1053            builder = builder.tag(quote_tag(
1054                id,
1055                msg.quote_id_relay_hint.as_ref(),
1056                msg.quote_author,
1057            ));
1058        }
1059        for tag in &msg.extra_tags {
1060            builder = builder.tag(tag.clone());
1061        }
1062        builder
1063    }
1064
1065    /// Author a NIP-53 `kind: 10312` room presence signal.
1066    #[must_use]
1067    pub fn room_presence(presence: &RoomPresence) -> Self {
1068        let mut builder = Self::new(KIND_ROOM_PRESENCE, "");
1069        builder = builder.tag(coordinate_tag(
1070            &presence.room,
1071            presence.room_relay_hint.as_ref(),
1072            presence.room_marker.as_deref(),
1073        ));
1074        if presence.hand_raised {
1075            builder = builder.tag(Tag::with(&TagKind::from_wire(HAND_TAG), ["1"]));
1076        }
1077        for tag in &presence.extra_tags {
1078            builder = builder.tag(tag.clone());
1079        }
1080        builder
1081    }
1082}
1083
1084fn push_option_text_tag(
1085    mut builder: EventBuilder,
1086    name: &str,
1087    value: Option<&str>,
1088) -> EventBuilder {
1089    if let Some(v) = value {
1090        builder = builder.tag(Tag::with(&TagKind::from_wire(name), [v.to_owned()]));
1091    }
1092    builder
1093}
1094
1095fn push_option_url_tag(mut builder: EventBuilder, name: &str, value: Option<&Url>) -> EventBuilder {
1096    if let Some(v) = value {
1097        builder = builder.tag(Tag::with(
1098            &TagKind::from_wire(name),
1099            [v.as_str().to_owned()],
1100        ));
1101    }
1102    builder
1103}
1104
1105fn relays_tag(relays: &[RelayUrl]) -> Tag {
1106    let mut cols: Vec<String> = Vec::with_capacity(relays.len() + 1);
1107    cols.push(RELAYS_TAG.to_owned());
1108    for relay in relays {
1109        cols.push(relay.as_str().to_owned());
1110    }
1111    Tag::new(cols).unwrap_or_else(|_| unreachable!("`cols` always contains the tag head"))
1112}
1113
1114fn quote_tag(id: EventId, relay: Option<&RelayUrl>, author: Option<PublicKey>) -> Tag {
1115    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::Q));
1116    let relay_str = relay.map_or_else(String::new, |r| r.as_str().to_owned());
1117    match author {
1118        Some(pk) => Tag::with(&head, [id.to_hex(), relay_str, pk.to_hex()]),
1119        None if relay.is_some() => Tag::with(&head, [id.to_hex(), relay_str]),
1120        None => Tag::with(&head, [id.to_hex()]),
1121    }
1122}
1123
1124#[cfg(test)]
1125mod tests {
1126    use super::*;
1127    use crate::Keys;
1128
1129    fn keys() -> Keys {
1130        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
1131    }
1132
1133    #[test]
1134    fn live_stream_round_trip() {
1135        let stream = LiveStream {
1136            identifier: "stream-1".into(),
1137            title: Some("Demo".into()),
1138            summary: Some("Live demo".into()),
1139            image: Some(Url::parse("https://example.com/p.jpg").unwrap()),
1140            streaming_url: Some(Url::parse("https://stream.example.com/live.m3u8").unwrap()),
1141            recording_url: None,
1142            starts: Some(Timestamp::from_secs(1_700_000_000)),
1143            ends: Some(Timestamp::from_secs(1_700_003_600)),
1144            status: Some(LiveStatus::Live),
1145            current_participants: Some(12),
1146            total_participants: Some(100),
1147            participants: vec![
1148                LiveParticipant::new(*keys().public_key())
1149                    .relay_hint(RelayUrl::parse("wss://relay.example/").unwrap())
1150                    .role("Host")
1151                    .proof("deadbeef"),
1152            ],
1153            hashtags: vec!["music".into()],
1154            relays: vec![RelayUrl::parse("wss://one.example/").unwrap()],
1155            pinned: vec![EventId::from_byte_array([0x11; 32])],
1156            extra_tags: Vec::new(),
1157        };
1158        let event = EventBuilder::live_stream(&stream)
1159            .sign_with_keys(&keys())
1160            .unwrap();
1161        let parsed = LiveStream::from_event(&event).unwrap();
1162        assert_eq!(parsed, stream);
1163    }
1164
1165    #[test]
1166    fn meeting_space_round_trip() {
1167        let space = MeetingSpace {
1168            identifier: "conf-1".into(),
1169            room: Some("Main Hall".into()),
1170            summary: Some("Primary space".into()),
1171            image: None,
1172            status: Some(SpaceStatus::Open),
1173            service_url: Some(Url::parse("https://meet.example.com/hall").unwrap()),
1174            endpoint_url: Some(Url::parse("https://api.example.com/hall").unwrap()),
1175            hashtags: vec!["conference".into()],
1176            participants: vec![LiveParticipant::new(*keys().public_key()).role("Host")],
1177            relays: vec![RelayUrl::parse("wss://relay.example/").unwrap()],
1178            extra_tags: Vec::new(),
1179        };
1180        let event = EventBuilder::meeting_space(&space)
1181            .sign_with_keys(&keys())
1182            .unwrap();
1183        let parsed = MeetingSpace::from_event(&event).unwrap();
1184        assert_eq!(parsed, space);
1185    }
1186
1187    #[test]
1188    fn meeting_room_round_trip() {
1189        let space = Coordinate::new(
1190            KIND_MEETING_SPACE,
1191            *keys().public_key(),
1192            "conf-1".to_owned(),
1193        );
1194        let room = MeetingRoom {
1195            identifier: "annual-2025".into(),
1196            space: Some(space),
1197            space_relay_hint: Some(RelayUrl::parse("wss://relay.example/").unwrap()),
1198            title: Some("Annual Meeting".into()),
1199            summary: Some("Yearly company-wide".into()),
1200            image: None,
1201            starts: Some(Timestamp::from_secs(1_700_000_000)),
1202            ends: Some(Timestamp::from_secs(1_700_003_600)),
1203            status: Some(LiveStatus::Live),
1204            total_participants: Some(180),
1205            current_participants: Some(175),
1206            participants: Vec::new(),
1207            extra_tags: Vec::new(),
1208        };
1209        let event = EventBuilder::meeting_room(&room)
1210            .unwrap()
1211            .sign_with_keys(&keys())
1212            .unwrap();
1213        let parsed = MeetingRoom::from_event(&event).unwrap();
1214        assert_eq!(parsed, room);
1215    }
1216
1217    #[test]
1218    fn meeting_room_missing_space_is_rejected() {
1219        let room = MeetingRoom::new("no-space");
1220        assert!(matches!(
1221            EventBuilder::meeting_room(&room),
1222            Err(LiveError::MissingAddress)
1223        ));
1224    }
1225
1226    #[test]
1227    fn live_chat_round_trip() {
1228        let host = Coordinate::new(
1229            KIND_LIVE_STREAM,
1230            *keys().public_key(),
1231            "stream-1".to_owned(),
1232        );
1233        let msg = LiveChatMessage {
1234            content: "hi".into(),
1235            host,
1236            host_relay_hint: Some(RelayUrl::parse("wss://relay.example/").unwrap()),
1237            host_marker: Some("root".into()),
1238            parent_id: Some(EventId::from_byte_array([0x22; 32])),
1239            parent_id_relay_hint: None,
1240            quote_id: Some(EventId::from_byte_array([0x33; 32])),
1241            quote_id_relay_hint: Some(RelayUrl::parse("wss://relay2.example/").unwrap()),
1242            quote_author: Some(*keys().public_key()),
1243            extra_tags: Vec::new(),
1244        };
1245        let event = EventBuilder::live_chat_message(&msg)
1246            .sign_with_keys(&keys())
1247            .unwrap();
1248        let parsed = LiveChatMessage::from_event(&event).unwrap();
1249        assert_eq!(parsed, msg);
1250    }
1251
1252    #[test]
1253    fn room_presence_round_trip() {
1254        let room = Coordinate::new(
1255            KIND_MEETING_SPACE,
1256            *keys().public_key(),
1257            "room-1".to_owned(),
1258        );
1259        let presence = RoomPresence::new(room).hand_raised(true);
1260        let event = EventBuilder::room_presence(&presence)
1261            .sign_with_keys(&keys())
1262            .unwrap();
1263        let parsed = RoomPresence::from_event(&event).unwrap();
1264        assert_eq!(parsed, presence);
1265    }
1266
1267    #[test]
1268    fn live_status_forward_compatible() {
1269        assert_eq!(
1270            LiveStatus::parse("unknown"),
1271            LiveStatus::Custom("unknown".into())
1272        );
1273        assert_eq!(
1274            SpaceStatus::parse("unknown"),
1275            SpaceStatus::Custom("unknown".into())
1276        );
1277    }
1278
1279    #[test]
1280    fn wrong_kind_is_rejected() {
1281        let event = EventBuilder::text_note("nope")
1282            .sign_with_keys(&keys())
1283            .unwrap();
1284        assert!(matches!(
1285            LiveStream::from_event(&event),
1286            Err(LiveError::WrongKind(_))
1287        ));
1288        assert!(matches!(
1289            MeetingSpace::from_event(&event),
1290            Err(LiveError::WrongKind(_))
1291        ));
1292        assert!(matches!(
1293            MeetingRoom::from_event(&event),
1294            Err(LiveError::WrongKind(_))
1295        ));
1296        assert!(matches!(
1297            LiveChatMessage::from_event(&event),
1298            Err(LiveError::WrongKind(_))
1299        ));
1300        assert!(matches!(
1301            RoomPresence::from_event(&event),
1302            Err(LiveError::WrongKind(_))
1303        ));
1304    }
1305}