Skip to main content

nula_core/nips/
nip29.rs

1//! [NIP-29] Relay-based Groups.
2//!
3//! Groups are relay-side closed-membership constructs. Every group has
4//! a string identifier and is hosted by exactly one relay (though a
5//! group can be forked across relays under the same id). The crate
6//! models four lanes:
7//!
8//! - **Membership** ([`JoinRequest`] / [`LeaveRequest`]) — events
9//!   sent by users.
10//! - **Moderation** ([`ModerationAction`] + [`ModerationKind`]) — the
11//!   `9000-9020` range of admin events that mutate group state.
12//! - **Group state** ([`GroupMetadata`] / [`GroupAdmins`] /
13//!   [`GroupMembers`] / [`GroupRoles`]) — addressable events the
14//!   relay master key publishes.
15//! - **Identifier** ([`GroupId`]) — the spec's `<host>'<group-id>`
16//!   pair, with `_` reserved for relay-local discussions.
17//!
18//! All event-authoring helpers carry the spec-required `h` tag for
19//! user-side events and the `d` tag for state events.
20//!
21//! [NIP-29]: https://github.com/nostr-protocol/nips/blob/master/29.md
22
23use thiserror::Error;
24
25use crate::event::{
26    Alphabet, Coordinate, Event, EventBuilder, EventId, EventIdError, Kind, SingleLetterTag, Tag,
27    TagKind,
28};
29use crate::key::{PublicKey, PublicKeyError};
30use crate::types::{Url, UrlError};
31
32/// `kind: 9000` — put-user moderation event.
33pub const KIND_GROUP_PUT_USER: Kind = Kind::GROUP_PUT_USER;
34/// `kind: 9001` — remove-user moderation event.
35pub const KIND_GROUP_REMOVE_USER: Kind = Kind::GROUP_REMOVE_USER;
36/// `kind: 9002` — edit-metadata moderation event.
37pub const KIND_GROUP_EDIT_METADATA: Kind = Kind::GROUP_EDIT_METADATA;
38/// `kind: 9005` — delete-event moderation event.
39pub const KIND_GROUP_DELETE_EVENT: Kind = Kind::GROUP_DELETE_EVENT;
40/// `kind: 9007` — create-group moderation event.
41pub const KIND_GROUP_CREATE: Kind = Kind::GROUP_CREATE;
42/// `kind: 9008` — delete-group moderation event.
43pub const KIND_GROUP_DELETE: Kind = Kind::GROUP_DELETE;
44/// `kind: 9009` — create-invite moderation event.
45pub const KIND_GROUP_CREATE_INVITE: Kind = Kind::GROUP_CREATE_INVITE;
46/// `kind: 9021` — group join request.
47pub const KIND_GROUP_JOIN_REQUEST: Kind = Kind::GROUP_JOIN_REQUEST;
48/// `kind: 9022` — group leave request.
49pub const KIND_GROUP_LEAVE_REQUEST: Kind = Kind::GROUP_LEAVE_REQUEST;
50/// `kind: 39000` — group metadata.
51pub const KIND_GROUP_METADATA: Kind = Kind::GROUP_METADATA;
52/// `kind: 39001` — group admins.
53pub const KIND_GROUP_ADMINS: Kind = Kind::GROUP_ADMINS;
54/// `kind: 39002` — group members.
55pub const KIND_GROUP_MEMBERS: Kind = Kind::GROUP_MEMBERS;
56/// `kind: 39003` — group roles.
57pub const KIND_GROUP_ROLES: Kind = Kind::GROUP_ROLES;
58
59const H_TAG: &str = "h";
60const PREVIOUS_TAG: &str = "previous";
61const NAME_TAG: &str = "name";
62const PICTURE_TAG: &str = "picture";
63const ABOUT_TAG: &str = "about";
64const PRIVATE_TAG: &str = "private";
65const RESTRICTED_TAG: &str = "restricted";
66const HIDDEN_TAG: &str = "hidden";
67const CLOSED_TAG: &str = "closed";
68const ROLE_TAG: &str = "role";
69const CODE_TAG: &str = "code";
70
71/// Sentinel reserved for the relay-local discussion group when the
72/// caller drops the `'<id>` part of a group reference.
73pub const RELAY_LOCAL_GROUP: &str = "_";
74
75/// Group identifier `<host>'<group-id>` (or just `<host>` ⇒
76/// [`RELAY_LOCAL_GROUP`]).
77#[derive(Debug, Clone, PartialEq, Eq, Hash)]
78pub struct GroupId {
79    /// Host portion (relay hostname without the `wss://` prefix).
80    pub host: String,
81    /// Per-group identifier (lowercase `[a-z0-9-_]+`).
82    pub id: String,
83}
84
85impl GroupId {
86    /// Construct a group reference, defaulting to the relay-local
87    /// sentinel id when `id` is `None`.
88    #[must_use]
89    pub fn new(host: impl Into<String>, id: Option<String>) -> Self {
90        Self {
91            host: host.into(),
92            id: id.unwrap_or_else(|| RELAY_LOCAL_GROUP.to_owned()),
93        }
94    }
95
96    /// Render as the spec's wire form (`<host>'<group-id>`). When the
97    /// id is the sentinel `_`, the trailing `'_` is omitted to match
98    /// shorthand notation.
99    #[must_use]
100    pub fn to_wire(&self) -> String {
101        if self.id == RELAY_LOCAL_GROUP {
102            self.host.clone()
103        } else {
104            format!("{}'{}", self.host, self.id)
105        }
106    }
107
108    /// Parse from `<host>['<id>]`.
109    ///
110    /// # Errors
111    ///
112    /// Returns [`GroupIdError`] when the `<host>` portion is empty
113    /// or when `<id>` contains characters outside `[a-z0-9-_]`.
114    pub fn parse(input: &str) -> Result<Self, GroupIdError> {
115        let (host, id) = input.split_once('\'').map_or_else(
116            || (input.to_owned(), None),
117            |(h, i)| (h.to_owned(), Some(i.to_owned())),
118        );
119        if host.is_empty() {
120            return Err(GroupIdError::EmptyHost);
121        }
122        if let Some(id_str) = &id
123            && !is_valid_id(id_str)
124        {
125            return Err(GroupIdError::InvalidId(id_str.clone()));
126        }
127        Ok(Self::new(host, id))
128    }
129}
130
131fn is_valid_id(id: &str) -> bool {
132    !id.is_empty()
133        && id
134            .bytes()
135            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
136}
137
138/// Errors raised by [`GroupId::parse`].
139#[derive(Debug, Error)]
140#[non_exhaustive]
141pub enum GroupIdError {
142    /// `<host>` portion was empty.
143    #[error("group id missing host portion")]
144    EmptyHost,
145    /// `<id>` portion contained non-`[a-z0-9-_]` characters.
146    #[error("group id `{0}` contains invalid characters (allowed: [a-z0-9-_])")]
147    InvalidId(String),
148}
149
150/// `["previous", ...]` 4-byte event-id-prefix references.
151pub type PreviousReferences = Vec<String>;
152
153/// `["h", "<group-id>"]` based event metadata.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct GroupRef {
156    /// Per-group identifier (string after the `'`).
157    pub id: String,
158    /// Optional 4-byte previous-event-id prefixes (anti-context-out).
159    pub previous: PreviousReferences,
160}
161
162impl GroupRef {
163    /// Construct a reference with no `previous` prefixes.
164    #[must_use]
165    pub fn new(id: impl Into<String>) -> Self {
166        Self {
167            id: id.into(),
168            previous: Vec::new(),
169        }
170    }
171}
172
173/// `kind: 9021` — request to join a group.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct JoinRequest {
176    /// Reason or note (mirrors `.content`).
177    pub reason: String,
178    /// Group identifier.
179    pub group: GroupRef,
180    /// Optional invite code (`code` tag).
181    pub code: Option<String>,
182    /// Forward-compatible passthrough for unknown tags.
183    pub extra_tags: Vec<Tag>,
184}
185
186/// `kind: 9022` — request to leave a group.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct LeaveRequest {
189    /// Reason or note (mirrors `.content`).
190    pub reason: String,
191    /// Group identifier.
192    pub group: GroupRef,
193    /// Forward-compatible passthrough for unknown tags.
194    pub extra_tags: Vec<Tag>,
195}
196
197/// Per-NIP-29 moderation kind plus a forward-compatible passthrough
198/// for the rest of the `9000..=9020` range.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub enum ModerationKind {
201    /// `9000` put-user.
202    PutUser {
203        /// Pubkey to add or update.
204        pubkey: PublicKey,
205        /// Optional list of role labels.
206        roles: Vec<String>,
207    },
208    /// `9001` remove-user.
209    RemoveUser(PublicKey),
210    /// `9002` edit-metadata.
211    EditMetadata(GroupMetadataPatch),
212    /// `9005` delete-event.
213    DeleteEvent(EventId),
214    /// `9007` create-group.
215    CreateGroup,
216    /// `9008` delete-group.
217    DeleteGroup,
218    /// `9009` create-invite.
219    CreateInvite {
220        /// Pre-authorisation code.
221        code: String,
222    },
223    /// Reserved range passthrough.
224    Custom {
225        /// Underlying numeric kind.
226        kind: Kind,
227        /// Verbatim tags carried by the moderation event (excluding
228        /// the canonical `h` and `previous` ones).
229        tags: Vec<Tag>,
230    },
231}
232
233/// Bitset for the four flag-style metadata tags (`private` /
234/// `restricted` / `hidden` / `closed`).
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
236pub struct GroupFlags(u8);
237
238impl GroupFlags {
239    /// `private` — only members can read.
240    pub const PRIVATE: Self = Self(0b0001);
241    /// `restricted` — only members can write.
242    pub const RESTRICTED: Self = Self(0b0010);
243    /// `hidden` — non-members cannot fetch metadata.
244    pub const HIDDEN: Self = Self(0b0100);
245    /// `closed` — join requests are ignored.
246    pub const CLOSED: Self = Self(0b1000);
247
248    /// Empty flag set.
249    #[must_use]
250    pub const fn empty() -> Self {
251        Self(0)
252    }
253
254    /// True when every bit in `other` is set in `self`.
255    #[must_use]
256    pub const fn contains(self, other: Self) -> bool {
257        (self.0 & other.0) == other.0
258    }
259}
260
261impl std::ops::BitOr for GroupFlags {
262    type Output = Self;
263
264    fn bitor(self, rhs: Self) -> Self {
265        Self(self.0 | rhs.0)
266    }
267}
268
269impl std::ops::BitOrAssign for GroupFlags {
270    fn bitor_assign(&mut self, rhs: Self) {
271        self.0 |= rhs.0;
272    }
273}
274
275/// Subset of [`GroupMetadata`] updatable through `kind: 9002`.
276#[derive(Debug, Clone, PartialEq, Eq, Default)]
277pub struct GroupMetadataPatch {
278    /// `name` tag.
279    pub name: Option<String>,
280    /// `picture` tag.
281    pub picture: Option<Url>,
282    /// `about` tag.
283    pub about: Option<String>,
284    /// Flag-style tags.
285    pub flags: GroupFlags,
286}
287
288/// `kind: 9000-9020` — moderation action authored by an admin (or
289/// the relay master key for `9007` / `9008`).
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub struct ModerationAction {
292    /// Reason or note (mirrors `.content`).
293    pub reason: String,
294    /// Group identifier.
295    pub group: GroupRef,
296    /// The action itself.
297    pub action: ModerationKind,
298    /// Forward-compatible passthrough for unknown tags.
299    pub extra_tags: Vec<Tag>,
300}
301
302/// `kind: 39000` — group metadata addressable event.
303#[derive(Debug, Clone, PartialEq, Eq, Default)]
304pub struct GroupMetadata {
305    /// Group identifier (`d` tag).
306    pub identifier: String,
307    /// `name` tag.
308    pub name: Option<String>,
309    /// `picture` tag.
310    pub picture: Option<Url>,
311    /// `about` tag.
312    pub about: Option<String>,
313    /// Flag-style tags.
314    pub flags: GroupFlags,
315    /// Forward-compatible passthrough for unknown tags.
316    pub extra_tags: Vec<Tag>,
317}
318
319/// A pubkey + roles row used by [`GroupAdmins`] / [`GroupMembers`].
320#[derive(Debug, Clone, PartialEq, Eq)]
321pub struct GroupAdminEntry {
322    /// Admin pubkey.
323    pub pubkey: PublicKey,
324    /// Role labels (zero or more, `["p", <pubkey>, <role>...]`).
325    pub roles: Vec<String>,
326}
327
328/// `kind: 39001` — group admins addressable event.
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub struct GroupAdmins {
331    /// Group identifier (`d` tag).
332    pub identifier: String,
333    /// Free-form description (mirrors `.content`).
334    pub content: String,
335    /// One row per admin.
336    pub admins: Vec<GroupAdminEntry>,
337    /// Forward-compatible passthrough for unknown tags.
338    pub extra_tags: Vec<Tag>,
339}
340
341/// `kind: 39002` — group members addressable event.
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct GroupMembers {
344    /// Group identifier (`d` tag).
345    pub identifier: String,
346    /// Free-form description (mirrors `.content`).
347    pub content: String,
348    /// One pubkey per member (no roles per spec).
349    pub members: Vec<PublicKey>,
350    /// Forward-compatible passthrough for unknown tags.
351    pub extra_tags: Vec<Tag>,
352}
353
354/// A `role` tag column on a [`GroupRoles`] event.
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct GroupRole {
357    /// Role label.
358    pub name: String,
359    /// Optional description.
360    pub description: Option<String>,
361}
362
363/// `kind: 39003` — group roles addressable event.
364#[derive(Debug, Clone, PartialEq, Eq)]
365pub struct GroupRoles {
366    /// Group identifier (`d` tag).
367    pub identifier: String,
368    /// Free-form description (mirrors `.content`).
369    pub content: String,
370    /// Role definitions in publication order.
371    pub roles: Vec<GroupRole>,
372    /// Forward-compatible passthrough for unknown tags.
373    pub extra_tags: Vec<Tag>,
374}
375
376/// Errors raised while parsing NIP-29 events.
377#[derive(Debug, Error)]
378#[non_exhaustive]
379pub enum GroupError {
380    /// Event kind does not match the expected NIP-29 kind.
381    #[error("unexpected kind for NIP-29 event: {}", .0.as_u16())]
382    WrongKind(Kind),
383    /// `h` tag missing on a user-side event.
384    #[error("NIP-29 event missing required `h` tag")]
385    MissingGroupRef,
386    /// `d` tag missing on a state event.
387    #[error("NIP-29 state event missing required `d` tag")]
388    MissingIdentifier,
389    /// `p` tag missing on a moderation event that requires it.
390    #[error("NIP-29 moderation event missing required `p` tag")]
391    MissingPubkey,
392    /// `e` tag missing on a `9005` delete-event.
393    #[error("NIP-29 delete-event missing required `e` tag")]
394    MissingEvent,
395    /// `code` tag missing on `9009` create-invite.
396    #[error("NIP-29 create-invite missing required `code` tag")]
397    MissingCode,
398    /// Group id parsing error.
399    #[error(transparent)]
400    InvalidGroupId(#[from] GroupIdError),
401    /// Wrapped pubkey parser error.
402    #[error(transparent)]
403    InvalidPublicKey(#[from] PublicKeyError),
404    /// Wrapped URL parser error.
405    #[error(transparent)]
406    InvalidUrl(#[from] UrlError),
407    /// Wrapped event-id parser error.
408    #[error(transparent)]
409    InvalidEventId(#[from] EventIdError),
410}
411
412fn h_tag_value(event: &Event) -> Option<&str> {
413    event
414        .tags
415        .iter()
416        .find(|tag| tag.name() == H_TAG)
417        .and_then(|tag| tag.get(1))
418}
419
420fn d_tag_value(event: &Event) -> Option<&str> {
421    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
422    event.tags.find_first(&head).and_then(|tag| tag.get(1))
423}
424
425fn previous_from_event(event: &Event) -> PreviousReferences {
426    event
427        .tags
428        .iter()
429        .find(|tag| tag.name() == PREVIOUS_TAG)
430        .map_or_else(Vec::new, |tag| {
431            tag.values().iter().skip(1).cloned().collect()
432        })
433}
434
435fn group_ref_tag(group: &GroupRef) -> Vec<Tag> {
436    let mut tags = vec![Tag::with(&TagKind::from_wire(H_TAG), [group.id.clone()])];
437    if !group.previous.is_empty() {
438        let mut cols = vec![PREVIOUS_TAG.to_owned()];
439        cols.extend(group.previous.iter().cloned());
440        if let Ok(tag) = Tag::new(cols) {
441            tags.push(tag);
442        }
443    }
444    tags
445}
446
447fn metadata_apply_flags(builder: &mut EventBuilder, patch: &GroupMetadataPatch) {
448    if let Some(name) = &patch.name {
449        *builder = builder
450            .clone()
451            .tag(Tag::with(&TagKind::from_wire(NAME_TAG), [name.clone()]));
452    }
453    if let Some(picture) = &patch.picture {
454        *builder = builder.clone().tag(Tag::with(
455            &TagKind::from_wire(PICTURE_TAG),
456            [picture.as_str().to_owned()],
457        ));
458    }
459    if let Some(about) = &patch.about {
460        *builder = builder
461            .clone()
462            .tag(Tag::with(&TagKind::from_wire(ABOUT_TAG), [about.clone()]));
463    }
464    for (bit, name) in [
465        (GroupFlags::PRIVATE, PRIVATE_TAG),
466        (GroupFlags::RESTRICTED, RESTRICTED_TAG),
467        (GroupFlags::HIDDEN, HIDDEN_TAG),
468        (GroupFlags::CLOSED, CLOSED_TAG),
469    ] {
470        if patch.flags.contains(bit) {
471            *builder = builder
472                .clone()
473                .tag(Tag::with(&TagKind::from_wire(name), Vec::<String>::new()));
474        }
475    }
476}
477
478impl JoinRequest {
479    /// Parse a `kind: 9021` join request.
480    ///
481    /// # Errors
482    ///
483    /// See [`GroupError`] for the failure modes.
484    pub fn from_event(event: &Event) -> Result<Self, GroupError> {
485        if event.kind != KIND_GROUP_JOIN_REQUEST {
486            return Err(GroupError::WrongKind(event.kind));
487        }
488        let id = h_tag_value(event)
489            .ok_or(GroupError::MissingGroupRef)?
490            .to_owned();
491        let mut code: Option<String> = None;
492        let mut extra_tags: Vec<Tag> = Vec::new();
493        for tag in &event.tags {
494            match tag.name() {
495                H_TAG | PREVIOUS_TAG => {}
496                CODE_TAG => code = tag.get(1).map(str::to_owned),
497                _ => extra_tags.push(tag.clone()),
498            }
499        }
500        Ok(Self {
501            reason: event.content.clone(),
502            group: GroupRef {
503                id,
504                previous: previous_from_event(event),
505            },
506            code,
507            extra_tags,
508        })
509    }
510}
511
512impl LeaveRequest {
513    /// Parse a `kind: 9022` leave request.
514    ///
515    /// # Errors
516    ///
517    /// See [`GroupError`] for the failure modes.
518    pub fn from_event(event: &Event) -> Result<Self, GroupError> {
519        if event.kind != KIND_GROUP_LEAVE_REQUEST {
520            return Err(GroupError::WrongKind(event.kind));
521        }
522        let id = h_tag_value(event)
523            .ok_or(GroupError::MissingGroupRef)?
524            .to_owned();
525        let mut extra_tags: Vec<Tag> = Vec::new();
526        for tag in &event.tags {
527            match tag.name() {
528                H_TAG | PREVIOUS_TAG => {}
529                _ => extra_tags.push(tag.clone()),
530            }
531        }
532        Ok(Self {
533            reason: event.content.clone(),
534            group: GroupRef {
535                id,
536                previous: previous_from_event(event),
537            },
538            extra_tags,
539        })
540    }
541}
542
543impl ModerationAction {
544    /// Parse a `kind: 9000-9020` moderation event.
545    ///
546    /// # Errors
547    ///
548    /// See [`GroupError`] for the failure modes.
549    pub fn from_event(event: &Event) -> Result<Self, GroupError> {
550        let kind = event.kind;
551        if !(9_000..=9_020).contains(&kind.as_u16()) {
552            return Err(GroupError::WrongKind(kind));
553        }
554        let group_id = h_tag_value(event)
555            .ok_or(GroupError::MissingGroupRef)?
556            .to_owned();
557        let action = parse_moderation_kind(event)?;
558        let mut extra_tags: Vec<Tag> = Vec::new();
559        for tag in &event.tags {
560            if !is_canonical_moderation_tag(tag, &action) {
561                extra_tags.push(tag.clone());
562            }
563        }
564        Ok(Self {
565            reason: event.content.clone(),
566            group: GroupRef {
567                id: group_id,
568                previous: previous_from_event(event),
569            },
570            action,
571            extra_tags,
572        })
573    }
574}
575
576fn is_canonical_moderation_tag(tag: &Tag, action: &ModerationKind) -> bool {
577    matches!(
578        (tag.name(), action),
579        (H_TAG | PREVIOUS_TAG, _)
580            | (
581                "p",
582                ModerationKind::PutUser { .. } | ModerationKind::RemoveUser(_)
583            )
584            | ("e", ModerationKind::DeleteEvent(_))
585            | (CODE_TAG, ModerationKind::CreateInvite { .. })
586            | (
587                NAME_TAG
588                    | PICTURE_TAG
589                    | ABOUT_TAG
590                    | PRIVATE_TAG
591                    | RESTRICTED_TAG
592                    | HIDDEN_TAG
593                    | CLOSED_TAG,
594                ModerationKind::EditMetadata(_)
595            )
596    )
597}
598
599fn parse_moderation_kind(event: &Event) -> Result<ModerationKind, GroupError> {
600    match event.kind {
601        KIND_GROUP_PUT_USER => {
602            let tag = event
603                .tags
604                .iter()
605                .find(|t| matches!(t.kind(), TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P))
606                .ok_or(GroupError::MissingPubkey)?;
607            let pk_hex = tag.get(1).ok_or(GroupError::MissingPubkey)?;
608            let pubkey = PublicKey::parse(pk_hex)?;
609            let roles = tag.values().iter().skip(2).cloned().collect();
610            Ok(ModerationKind::PutUser { pubkey, roles })
611        }
612        KIND_GROUP_REMOVE_USER => {
613            let tag = event
614                .tags
615                .iter()
616                .find(|t| matches!(t.kind(), TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P))
617                .ok_or(GroupError::MissingPubkey)?;
618            let pk_hex = tag.get(1).ok_or(GroupError::MissingPubkey)?;
619            Ok(ModerationKind::RemoveUser(PublicKey::parse(pk_hex)?))
620        }
621        KIND_GROUP_EDIT_METADATA => Ok(ModerationKind::EditMetadata(parse_metadata_patch(event)?)),
622        KIND_GROUP_DELETE_EVENT => {
623            let tag = event
624                .tags
625                .iter()
626                .find(|t| matches!(t.kind(), TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::E))
627                .ok_or(GroupError::MissingEvent)?;
628            let id_hex = tag.get(1).ok_or(GroupError::MissingEvent)?;
629            Ok(ModerationKind::DeleteEvent(EventId::parse(id_hex)?))
630        }
631        KIND_GROUP_CREATE => Ok(ModerationKind::CreateGroup),
632        KIND_GROUP_DELETE => Ok(ModerationKind::DeleteGroup),
633        KIND_GROUP_CREATE_INVITE => {
634            let code = event
635                .tags
636                .iter()
637                .find(|t| t.name() == CODE_TAG)
638                .and_then(|t| t.get(1))
639                .ok_or(GroupError::MissingCode)?
640                .to_owned();
641            Ok(ModerationKind::CreateInvite { code })
642        }
643        other => Ok(ModerationKind::Custom {
644            kind: other,
645            tags: event
646                .tags
647                .iter()
648                .filter(|t| t.name() != H_TAG && t.name() != PREVIOUS_TAG)
649                .cloned()
650                .collect(),
651        }),
652    }
653}
654
655fn parse_metadata_patch(event: &Event) -> Result<GroupMetadataPatch, GroupError> {
656    let mut out = GroupMetadataPatch::default();
657    for tag in &event.tags {
658        match tag.name() {
659            NAME_TAG => out.name = tag.get(1).map(str::to_owned),
660            PICTURE_TAG => {
661                if let Some(raw) = tag.get(1) {
662                    out.picture = Some(Url::parse(raw)?);
663                }
664            }
665            ABOUT_TAG => out.about = tag.get(1).map(str::to_owned),
666            PRIVATE_TAG => out.flags |= GroupFlags::PRIVATE,
667            RESTRICTED_TAG => out.flags |= GroupFlags::RESTRICTED,
668            HIDDEN_TAG => out.flags |= GroupFlags::HIDDEN,
669            CLOSED_TAG => out.flags |= GroupFlags::CLOSED,
670            _ => {}
671        }
672    }
673    Ok(out)
674}
675
676impl GroupMetadata {
677    /// Build the addressable coordinate for this metadata.
678    #[must_use]
679    pub fn coordinate(&self, author: PublicKey) -> Coordinate {
680        Coordinate::new(KIND_GROUP_METADATA, author, self.identifier.clone())
681    }
682
683    /// Parse a `kind: 39000` group-metadata event.
684    ///
685    /// # Errors
686    ///
687    /// See [`GroupError`] for the failure modes.
688    pub fn from_event(event: &Event) -> Result<Self, GroupError> {
689        if event.kind != KIND_GROUP_METADATA {
690            return Err(GroupError::WrongKind(event.kind));
691        }
692        let identifier = d_tag_value(event)
693            .ok_or(GroupError::MissingIdentifier)?
694            .to_owned();
695        let patch = parse_metadata_patch(event)?;
696        let extra_tags = event
697            .tags
698            .iter()
699            .filter(|tag| {
700                let name = tag.name();
701                name != "d"
702                    && name != NAME_TAG
703                    && name != PICTURE_TAG
704                    && name != ABOUT_TAG
705                    && name != PRIVATE_TAG
706                    && name != RESTRICTED_TAG
707                    && name != HIDDEN_TAG
708                    && name != CLOSED_TAG
709            })
710            .cloned()
711            .collect();
712        Ok(Self {
713            identifier,
714            name: patch.name,
715            picture: patch.picture,
716            about: patch.about,
717            flags: patch.flags,
718            extra_tags,
719        })
720    }
721}
722
723impl GroupAdmins {
724    /// Parse a `kind: 39001` group-admins event.
725    ///
726    /// # Errors
727    ///
728    /// See [`GroupError`] for the failure modes.
729    pub fn from_event(event: &Event) -> Result<Self, GroupError> {
730        if event.kind != KIND_GROUP_ADMINS {
731            return Err(GroupError::WrongKind(event.kind));
732        }
733        let identifier = d_tag_value(event)
734            .ok_or(GroupError::MissingIdentifier)?
735            .to_owned();
736        let mut admins: Vec<GroupAdminEntry> = Vec::new();
737        let mut extra_tags: Vec<Tag> = Vec::new();
738        for tag in &event.tags {
739            match tag.kind() {
740                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P => {
741                    let pk_hex = tag.get(1).ok_or(GroupError::MissingPubkey)?;
742                    let pubkey = PublicKey::parse(pk_hex)?;
743                    let roles = tag.values().iter().skip(2).cloned().collect();
744                    admins.push(GroupAdminEntry { pubkey, roles });
745                }
746                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
747                _ => extra_tags.push(tag.clone()),
748            }
749        }
750        Ok(Self {
751            identifier,
752            content: event.content.clone(),
753            admins,
754            extra_tags,
755        })
756    }
757}
758
759impl GroupMembers {
760    /// Parse a `kind: 39002` group-members event.
761    ///
762    /// # Errors
763    ///
764    /// See [`GroupError`] for the failure modes.
765    pub fn from_event(event: &Event) -> Result<Self, GroupError> {
766        if event.kind != KIND_GROUP_MEMBERS {
767            return Err(GroupError::WrongKind(event.kind));
768        }
769        let identifier = d_tag_value(event)
770            .ok_or(GroupError::MissingIdentifier)?
771            .to_owned();
772        let mut members: Vec<PublicKey> = Vec::new();
773        let mut extra_tags: Vec<Tag> = Vec::new();
774        for tag in &event.tags {
775            match tag.kind() {
776                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P => {
777                    let pk_hex = tag.get(1).ok_or(GroupError::MissingPubkey)?;
778                    members.push(PublicKey::parse(pk_hex)?);
779                }
780                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {}
781                _ => extra_tags.push(tag.clone()),
782            }
783        }
784        Ok(Self {
785            identifier,
786            content: event.content.clone(),
787            members,
788            extra_tags,
789        })
790    }
791}
792
793impl GroupRoles {
794    /// Parse a `kind: 39003` group-roles event.
795    ///
796    /// # Errors
797    ///
798    /// See [`GroupError`] for the failure modes.
799    pub fn from_event(event: &Event) -> Result<Self, GroupError> {
800        if event.kind != KIND_GROUP_ROLES {
801            return Err(GroupError::WrongKind(event.kind));
802        }
803        let identifier = d_tag_value(event)
804            .ok_or(GroupError::MissingIdentifier)?
805            .to_owned();
806        let mut roles: Vec<GroupRole> = Vec::new();
807        let mut extra_tags: Vec<Tag> = Vec::new();
808        for tag in &event.tags {
809            match tag.name() {
810                ROLE_TAG => {
811                    let name = tag.get(1).ok_or(GroupError::MissingPubkey)?.to_owned();
812                    let description = tag.get(2).map(str::to_owned);
813                    roles.push(GroupRole { name, description });
814                }
815                "d" => {}
816                _ => extra_tags.push(tag.clone()),
817            }
818        }
819        Ok(Self {
820            identifier,
821            content: event.content.clone(),
822            roles,
823            extra_tags,
824        })
825    }
826}
827
828impl EventBuilder {
829    /// Author a NIP-29 `kind: 9021` join request.
830    #[must_use]
831    pub fn group_join_request(req: &JoinRequest) -> Self {
832        let mut builder = Self::new(KIND_GROUP_JOIN_REQUEST, req.reason.clone());
833        for tag in group_ref_tag(&req.group) {
834            builder = builder.tag(tag);
835        }
836        if let Some(code) = &req.code {
837            builder = builder.tag(Tag::with(&TagKind::from_wire(CODE_TAG), [code.clone()]));
838        }
839        for tag in &req.extra_tags {
840            builder = builder.tag(tag.clone());
841        }
842        builder
843    }
844
845    /// Author a NIP-29 `kind: 9022` leave request.
846    #[must_use]
847    pub fn group_leave_request(req: &LeaveRequest) -> Self {
848        let mut builder = Self::new(KIND_GROUP_LEAVE_REQUEST, req.reason.clone());
849        for tag in group_ref_tag(&req.group) {
850            builder = builder.tag(tag);
851        }
852        for tag in &req.extra_tags {
853            builder = builder.tag(tag.clone());
854        }
855        builder
856    }
857
858    /// Author a NIP-29 moderation event.
859    #[must_use]
860    pub fn group_moderation(action: &ModerationAction) -> Self {
861        let kind = match &action.action {
862            ModerationKind::PutUser { .. } => KIND_GROUP_PUT_USER,
863            ModerationKind::RemoveUser(_) => KIND_GROUP_REMOVE_USER,
864            ModerationKind::EditMetadata(_) => KIND_GROUP_EDIT_METADATA,
865            ModerationKind::DeleteEvent(_) => KIND_GROUP_DELETE_EVENT,
866            ModerationKind::CreateGroup => KIND_GROUP_CREATE,
867            ModerationKind::DeleteGroup => KIND_GROUP_DELETE,
868            ModerationKind::CreateInvite { .. } => KIND_GROUP_CREATE_INVITE,
869            ModerationKind::Custom { kind, .. } => *kind,
870        };
871        let mut builder = Self::new(kind, action.reason.clone());
872        for tag in group_ref_tag(&action.group) {
873            builder = builder.tag(tag);
874        }
875        match &action.action {
876            ModerationKind::PutUser { pubkey, roles } => {
877                let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
878                let mut cols = vec![pubkey.to_hex()];
879                cols.extend(roles.iter().cloned());
880                builder = builder.tag(Tag::with(&head, cols));
881            }
882            ModerationKind::RemoveUser(pubkey) => {
883                builder = builder.tag(Tag::p(*pubkey));
884            }
885            ModerationKind::EditMetadata(patch) => metadata_apply_flags(&mut builder, patch),
886            ModerationKind::DeleteEvent(id) => builder = builder.tag(Tag::e(*id)),
887            ModerationKind::CreateGroup | ModerationKind::DeleteGroup => {}
888            ModerationKind::CreateInvite { code } => {
889                builder = builder.tag(Tag::with(&TagKind::from_wire(CODE_TAG), [code.clone()]));
890            }
891            ModerationKind::Custom { tags, .. } => {
892                for tag in tags {
893                    builder = builder.tag(tag.clone());
894                }
895            }
896        }
897        for tag in &action.extra_tags {
898            builder = builder.tag(tag.clone());
899        }
900        builder
901    }
902
903    /// Author a NIP-29 `kind: 39000` group-metadata event.
904    #[must_use]
905    pub fn group_metadata(metadata: &GroupMetadata) -> Self {
906        let mut builder = Self::new(KIND_GROUP_METADATA, "");
907        builder = builder.tag(Tag::d(&metadata.identifier));
908        let patch = GroupMetadataPatch {
909            name: metadata.name.clone(),
910            picture: metadata.picture.clone(),
911            about: metadata.about.clone(),
912            flags: metadata.flags,
913        };
914        metadata_apply_flags(&mut builder, &patch);
915        for tag in &metadata.extra_tags {
916            builder = builder.tag(tag.clone());
917        }
918        builder
919    }
920
921    /// Author a NIP-29 `kind: 39001` group-admins event.
922    #[must_use]
923    pub fn group_admins(admins: &GroupAdmins) -> Self {
924        let mut builder = Self::new(KIND_GROUP_ADMINS, admins.content.clone());
925        builder = builder.tag(Tag::d(&admins.identifier));
926        for entry in &admins.admins {
927            let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
928            let mut cols = vec![entry.pubkey.to_hex()];
929            cols.extend(entry.roles.iter().cloned());
930            builder = builder.tag(Tag::with(&head, cols));
931        }
932        for tag in &admins.extra_tags {
933            builder = builder.tag(tag.clone());
934        }
935        builder
936    }
937
938    /// Author a NIP-29 `kind: 39002` group-members event.
939    #[must_use]
940    pub fn group_members(members: &GroupMembers) -> Self {
941        let mut builder = Self::new(KIND_GROUP_MEMBERS, members.content.clone());
942        builder = builder.tag(Tag::d(&members.identifier));
943        for pubkey in &members.members {
944            builder = builder.tag(Tag::p(*pubkey));
945        }
946        for tag in &members.extra_tags {
947            builder = builder.tag(tag.clone());
948        }
949        builder
950    }
951
952    /// Author a NIP-29 `kind: 39003` group-roles event.
953    #[must_use]
954    pub fn group_roles(roles: &GroupRoles) -> Self {
955        let mut builder = Self::new(KIND_GROUP_ROLES, roles.content.clone());
956        builder = builder.tag(Tag::d(&roles.identifier));
957        for role in &roles.roles {
958            let head = TagKind::from_wire(ROLE_TAG);
959            let cols = role.description.as_ref().map_or_else(
960                || vec![role.name.clone()],
961                |desc| vec![role.name.clone(), desc.clone()],
962            );
963            builder = builder.tag(Tag::with(&head, cols));
964        }
965        for tag in &roles.extra_tags {
966            builder = builder.tag(tag.clone());
967        }
968        builder
969    }
970}
971
972#[cfg(test)]
973mod tests {
974    use super::*;
975    use crate::Keys;
976
977    fn keys() -> Keys {
978        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
979    }
980
981    #[test]
982    fn group_id_parse_and_render() {
983        let g = GroupId::parse("groups.example.com'pizzalovers").unwrap();
984        assert_eq!(g.host, "groups.example.com");
985        assert_eq!(g.id, "pizzalovers");
986        assert_eq!(g.to_wire(), "groups.example.com'pizzalovers");
987
988        let local = GroupId::parse("groups.example.com").unwrap();
989        assert_eq!(local.id, RELAY_LOCAL_GROUP);
990        assert_eq!(local.to_wire(), "groups.example.com");
991    }
992
993    #[test]
994    fn group_id_invalid_chars() {
995        assert!(matches!(
996            GroupId::parse("groups.example.com'BAD ID"),
997            Err(GroupIdError::InvalidId(_))
998        ));
999    }
1000
1001    #[test]
1002    fn join_request_round_trip() {
1003        let req = JoinRequest {
1004            reason: "please".into(),
1005            group: GroupRef::new("pizzalovers"),
1006            code: Some("invite-1".into()),
1007            extra_tags: Vec::new(),
1008        };
1009        let event = EventBuilder::group_join_request(&req)
1010            .sign_with_keys(&keys())
1011            .unwrap();
1012        let parsed = JoinRequest::from_event(&event).unwrap();
1013        assert_eq!(parsed, req);
1014    }
1015
1016    #[test]
1017    fn put_user_moderation_round_trip() {
1018        let action = ModerationAction {
1019            reason: "promotion".into(),
1020            group: GroupRef::new("pizzalovers"),
1021            action: ModerationKind::PutUser {
1022                pubkey: *keys().public_key(),
1023                roles: vec!["ceo".into()],
1024            },
1025            extra_tags: Vec::new(),
1026        };
1027        let event = EventBuilder::group_moderation(&action)
1028            .sign_with_keys(&keys())
1029            .unwrap();
1030        let parsed = ModerationAction::from_event(&event).unwrap();
1031        assert_eq!(parsed.group.id, "pizzalovers");
1032        match parsed.action {
1033            ModerationKind::PutUser { pubkey, roles } => {
1034                assert_eq!(pubkey, *keys().public_key());
1035                assert_eq!(roles, vec!["ceo".to_owned()]);
1036            }
1037            other => panic!("unexpected moderation kind {other:?}"),
1038        }
1039    }
1040
1041    #[test]
1042    fn metadata_round_trip() {
1043        let metadata = GroupMetadata {
1044            identifier: "pizzalovers".into(),
1045            name: Some("Pizza Lovers".into()),
1046            picture: Some(Url::parse("https://pizza.example/icon.png").unwrap()),
1047            about: Some("a group for pizza fans".into()),
1048            flags: GroupFlags::PRIVATE | GroupFlags::CLOSED,
1049            extra_tags: Vec::new(),
1050        };
1051        let event = EventBuilder::group_metadata(&metadata)
1052            .sign_with_keys(&keys())
1053            .unwrap();
1054        let parsed = GroupMetadata::from_event(&event).unwrap();
1055        assert_eq!(parsed, metadata);
1056    }
1057
1058    #[test]
1059    fn members_round_trip() {
1060        let members = GroupMembers {
1061            identifier: "pizzalovers".into(),
1062            content: "members".into(),
1063            members: vec![*keys().public_key()],
1064            extra_tags: Vec::new(),
1065        };
1066        let event = EventBuilder::group_members(&members)
1067            .sign_with_keys(&keys())
1068            .unwrap();
1069        let parsed = GroupMembers::from_event(&event).unwrap();
1070        assert_eq!(parsed, members);
1071    }
1072
1073    #[test]
1074    fn roles_round_trip() {
1075        let roles = GroupRoles {
1076            identifier: "pizzalovers".into(),
1077            content: "roles".into(),
1078            roles: vec![
1079                GroupRole {
1080                    name: "ceo".into(),
1081                    description: Some("the leader".into()),
1082                },
1083                GroupRole {
1084                    name: "chef".into(),
1085                    description: None,
1086                },
1087            ],
1088            extra_tags: Vec::new(),
1089        };
1090        let event = EventBuilder::group_roles(&roles)
1091            .sign_with_keys(&keys())
1092            .unwrap();
1093        let parsed = GroupRoles::from_event(&event).unwrap();
1094        assert_eq!(parsed, roles);
1095    }
1096
1097    #[test]
1098    fn delete_event_moderation_round_trip() {
1099        let event_id = EventId::from_byte_array([0x44; 32]);
1100        let action = ModerationAction {
1101            reason: "spam".into(),
1102            group: GroupRef::new("pizzalovers"),
1103            action: ModerationKind::DeleteEvent(event_id),
1104            extra_tags: Vec::new(),
1105        };
1106        let event = EventBuilder::group_moderation(&action)
1107            .sign_with_keys(&keys())
1108            .unwrap();
1109        let parsed = ModerationAction::from_event(&event).unwrap();
1110        assert_eq!(parsed.action, ModerationKind::DeleteEvent(event_id));
1111    }
1112}