1use 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
38pub const KIND_LIVE_STREAM: Kind = Kind::LIVE_STREAM;
40
41pub const KIND_MEETING_SPACE: Kind = Kind::MEETING_SPACE;
43
44pub const KIND_MEETING_ROOM: Kind = Kind::MEETING_ROOM;
46
47pub const KIND_LIVE_CHAT: Kind = Kind::LIVE_CHAT_MESSAGE;
49
50pub 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
73pub enum LiveStatus {
74 Planned,
76 Live,
78 Ended,
80 Custom(String),
82}
83
84impl LiveStatus {
85 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
116pub enum SpaceStatus {
117 Open,
119 Private,
121 Closed,
123 Custom(String),
125}
126
127impl SpaceStatus {
128 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct LiveParticipant {
160 pub pubkey: PublicKey,
162 pub relay_hint: Option<RelayUrl>,
164 pub role: Option<String>,
166 pub proof: Option<String>,
170}
171
172impl LiveParticipant {
173 #[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 #[must_use]
186 pub fn relay_hint(mut self, relay: RelayUrl) -> Self {
187 self.relay_hint = Some(relay);
188 self
189 }
190
191 #[must_use]
193 pub fn role(mut self, role: impl Into<String>) -> Self {
194 self.role = Some(role.into());
195 self
196 }
197
198 #[must_use]
200 pub fn proof(mut self, proof: impl Into<String>) -> Self {
201 self.proof = Some(proof.into());
202 self
203 }
204
205 #[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 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
254pub struct LiveStream {
255 pub identifier: String,
257 pub title: Option<String>,
259 pub summary: Option<String>,
261 pub image: Option<Url>,
263 pub streaming_url: Option<Url>,
265 pub recording_url: Option<Url>,
267 pub starts: Option<Timestamp>,
269 pub ends: Option<Timestamp>,
271 pub status: Option<LiveStatus>,
273 pub current_participants: Option<u64>,
275 pub total_participants: Option<u64>,
277 pub participants: Vec<LiveParticipant>,
279 pub hashtags: Vec<String>,
281 pub relays: Vec<RelayUrl>,
283 pub pinned: Vec<EventId>,
285 pub extra_tags: Vec<Tag>,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Default)]
291pub struct MeetingSpace {
292 pub identifier: String,
294 pub room: Option<String>,
296 pub summary: Option<String>,
298 pub image: Option<Url>,
300 pub status: Option<SpaceStatus>,
302 pub service_url: Option<Url>,
304 pub endpoint_url: Option<Url>,
306 pub hashtags: Vec<String>,
308 pub participants: Vec<LiveParticipant>,
310 pub relays: Vec<RelayUrl>,
312 pub extra_tags: Vec<Tag>,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq, Default)]
319pub struct MeetingRoom {
320 pub identifier: String,
322 pub space: Option<Coordinate>,
324 pub space_relay_hint: Option<RelayUrl>,
326 pub title: Option<String>,
328 pub summary: Option<String>,
330 pub image: Option<Url>,
332 pub starts: Option<Timestamp>,
334 pub ends: Option<Timestamp>,
336 pub status: Option<LiveStatus>,
338 pub total_participants: Option<u64>,
340 pub current_participants: Option<u64>,
342 pub participants: Vec<LiveParticipant>,
344 pub extra_tags: Vec<Tag>,
346}
347
348#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct LiveChatMessage {
351 pub content: String,
353 pub host: Coordinate,
356 pub host_relay_hint: Option<RelayUrl>,
358 pub host_marker: Option<String>,
360 pub parent_id: Option<EventId>,
362 pub parent_id_relay_hint: Option<RelayUrl>,
364 pub quote_id: Option<EventId>,
366 pub quote_id_relay_hint: Option<RelayUrl>,
368 pub quote_author: Option<PublicKey>,
370 pub extra_tags: Vec<Tag>,
372}
373
374#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct RoomPresence {
377 pub room: Coordinate,
379 pub room_relay_hint: Option<RelayUrl>,
381 pub room_marker: Option<String>,
383 pub hand_raised: bool,
385 pub extra_tags: Vec<Tag>,
387}
388
389#[derive(Debug, Error)]
391#[non_exhaustive]
392pub enum LiveError {
393 #[error("unexpected kind for NIP-53 event: {}", .0.as_u16())]
395 WrongKind(Kind),
396 #[error("NIP-53 event missing `d` tag")]
398 MissingIdentifier,
399 #[error("NIP-53 event missing required `a` tag")]
402 MissingAddress,
403 #[error("`p` participant tag missing pubkey")]
405 MalformedParticipant,
406 #[error("invalid numeric tag `{tag}` value `{value}`")]
409 InvalidNumber {
410 tag: String,
412 value: String,
414 },
415 #[error(transparent)]
417 InvalidPublicKey(#[from] PublicKeyError),
418 #[error(transparent)]
420 InvalidRelayUrl(#[from] RelayUrlError),
421 #[error(transparent)]
423 InvalidUrl(#[from] UrlError),
424 #[error(transparent)]
426 InvalidTimestamp(#[from] TimestampError),
427 #[error(transparent)]
429 InvalidEventId(#[from] EventIdError),
430 #[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 #[must_use]
492 pub fn new(identifier: impl Into<String>) -> Self {
493 Self {
494 identifier: identifier.into(),
495 ..Self::default()
496 }
497 }
498
499 #[must_use]
501 pub fn coordinate(&self, author: PublicKey) -> Coordinate {
502 Coordinate::new(KIND_LIVE_STREAM, author, self.identifier.clone())
503 }
504
505 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 #[must_use]
615 pub fn new(identifier: impl Into<String>) -> Self {
616 Self {
617 identifier: identifier.into(),
618 ..Self::default()
619 }
620 }
621
622 #[must_use]
624 pub fn coordinate(&self, author: PublicKey) -> Coordinate {
625 Coordinate::new(KIND_MEETING_SPACE, author, self.identifier.clone())
626 }
627
628 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 #[must_use]
682 pub fn new(identifier: impl Into<String>) -> Self {
683 Self {
684 identifier: identifier.into(),
685 ..Self::default()
686 }
687 }
688
689 #[must_use]
691 pub fn coordinate(&self, author: PublicKey) -> Coordinate {
692 Coordinate::new(KIND_MEETING_ROOM, author, self.identifier.clone())
693 }
694
695 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 #[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 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 #[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 #[must_use]
853 pub const fn hand_raised(mut self, raised: bool) -> Self {
854 self.hand_raised = raised;
855 self
856 }
857
858 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 #[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 #[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 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 #[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 #[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}