Skip to main content

nula_core/nips/
nip72.rs

1//! [NIP-72] Moderated Communities (Reddit-style).
2//!
3//! Two new kinds drive the spec:
4//!
5//! | Kind   | Meaning                              | Type        |
6//! |--------|--------------------------------------|-------------|
7//! | 34550  | Community definition                 | Addressable |
8//! | 4550   | Post approval                        | Regular     |
9//!
10//! Posts inside a community are normal **NIP-22** `kind: 1111`
11//! comments tagged with the community `A`/`a` coordinate. The crate
12//! already ships [`crate::nips::nip22::Comment`]; we add NIP-72
13//! convenience constructors (`community_top_level_post`,
14//! `community_reply_post`) so call-sites don't have to wire the
15//! `K=34550` + `P=community-author` ceremony by hand.
16//!
17//! # Why a typed module
18//!
19//! Upstream `rust-nostr` does not ship a NIP-72 module — community
20//! definitions and approvals must be hand-rolled. We model:
21//!
22//! - [`CommunityDefinition`] — the kind 34550 bundle: `d`,
23//!   `name`, `description`, `image` (with optional dimensions),
24//!   moderators, and relay hints with the four spec markers
25//!   (`author`, `requests`, `approvals`, default).
26//! - [`PostApproval`] — the kind 4550 bundle covering all three
27//!   approval flavours (`e`-tag, `a`-tag, both) the spec allows
28//!   for replaceable events.
29//! - [`CommunityRelay`] / [`CommunityRelayMarker`] —
30//!   forward-compatible enum for relay markers; unknown markers
31//!   round-trip via `Other(String)`.
32//!
33//! [NIP-72]: https://github.com/nostr-protocol/nips/blob/master/72.md
34
35use thiserror::Error;
36
37use crate::event::{
38    Alphabet, Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind,
39    SingleLetterTag, Tag, TagKind, Tags,
40};
41use crate::key::{PublicKey, PublicKeyError};
42use crate::nips::nip22::{Comment, CommentScope};
43use crate::types::{ImageDimensions, ImageError, RelayUrl, RelayUrlError};
44
45/// `kind: 34550` — community definition (addressable).
46pub const KIND_COMMUNITY_DEFINITION: Kind = Kind::new(34_550);
47/// `kind: 4550` — post approval.
48pub const KIND_POST_APPROVAL: Kind = Kind::new(4_550);
49
50/// Marker on a `relay` tag inside a community definition.
51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52#[non_exhaustive]
53pub enum CommunityRelayMarker {
54    /// `author` — relay hosting the community owner's `kind: 0`.
55    Author,
56    /// `requests` — relay where post requests are sent.
57    Requests,
58    /// `approvals` — relay where approval events are sent.
59    Approvals,
60    /// No marker — generic recommended relay (spec allows this form).
61    Default,
62    /// Forward-compatible passthrough.
63    Other(String),
64}
65
66impl CommunityRelayMarker {
67    /// Render to wire form.
68    #[must_use]
69    pub const fn as_str(&self) -> Option<&str> {
70        match self {
71            Self::Author => Some("author"),
72            Self::Requests => Some("requests"),
73            Self::Approvals => Some("approvals"),
74            Self::Default => None,
75            Self::Other(s) => Some(s.as_str()),
76        }
77    }
78
79    /// Parse a marker. `None` collapses to [`Self::Default`].
80    #[must_use]
81    pub fn parse(marker: Option<&str>) -> Self {
82        match marker {
83            None => Self::Default,
84            Some("author") => Self::Author,
85            Some("requests") => Self::Requests,
86            Some("approvals") => Self::Approvals,
87            Some(other) => Self::Other(other.to_owned()),
88        }
89    }
90}
91
92/// One community-defined `relay` tag.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct CommunityRelay {
95    /// Relay URL.
96    pub url: RelayUrl,
97    /// Marker (or [`CommunityRelayMarker::Default`] for unmarked).
98    pub marker: CommunityRelayMarker,
99}
100
101impl CommunityRelay {
102    /// Build a relay with a specific marker.
103    #[must_use]
104    pub const fn new(url: RelayUrl, marker: CommunityRelayMarker) -> Self {
105        Self { url, marker }
106    }
107
108    /// Build an unmarked relay.
109    #[must_use]
110    pub const fn unmarked(url: RelayUrl) -> Self {
111        Self {
112            url,
113            marker: CommunityRelayMarker::Default,
114        }
115    }
116}
117
118/// `image` tag inside a community definition.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct CommunityImage {
121    /// Image URL. Spec leaves the format open so we keep a string.
122    pub url: String,
123    /// Optional `<width>x<height>` (NIP-94-style).
124    pub dim: Option<ImageDimensions>,
125}
126
127impl CommunityImage {
128    /// Build an image without explicit dimensions.
129    #[must_use]
130    pub fn new(url: impl Into<String>) -> Self {
131        Self {
132            url: url.into(),
133            dim: None,
134        }
135    }
136
137    /// Set the dimensions.
138    #[must_use]
139    pub const fn dim(mut self, dim: ImageDimensions) -> Self {
140        self.dim = Some(dim);
141        self
142    }
143}
144
145/// One moderator entry inside a community definition.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct CommunityModerator {
148    /// Moderator pubkey.
149    pub pubkey: PublicKey,
150    /// Optional recommended relay for fetching their key / activity.
151    pub relay_hint: Option<RelayUrl>,
152}
153
154impl CommunityModerator {
155    /// Build a moderator without a relay hint.
156    #[must_use]
157    pub const fn new(pubkey: PublicKey) -> Self {
158        Self {
159            pubkey,
160            relay_hint: None,
161        }
162    }
163
164    /// Set the relay hint.
165    #[must_use]
166    pub fn relay_hint(mut self, hint: RelayUrl) -> Self {
167        self.relay_hint = Some(hint);
168        self
169    }
170}
171
172/// Typed bundle for a `kind: 34550` community definition.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct CommunityDefinition {
175    /// `d` tag identifier (the `(pubkey, 34550, d)` coordinate's `d`).
176    pub identifier: String,
177    /// `name` tag (defaults to the identifier when absent).
178    pub name: Option<String>,
179    /// `description` tag.
180    pub description: Option<String>,
181    /// `image` tag.
182    pub image: Option<CommunityImage>,
183    /// Moderators (`p` tags with the `"moderator"` marker).
184    pub moderators: Vec<CommunityModerator>,
185    /// Relay tags (with optional markers).
186    pub relays: Vec<CommunityRelay>,
187    /// Forward-compatible passthrough of any other `tags` rows.
188    pub extra_tags: Vec<Tag>,
189}
190
191impl CommunityDefinition {
192    /// Construct a community definition with only the `d` identifier
193    /// set.
194    #[must_use]
195    pub fn new(identifier: impl Into<String>) -> Self {
196        Self {
197            identifier: identifier.into(),
198            name: None,
199            description: None,
200            image: None,
201            moderators: Vec::new(),
202            relays: Vec::new(),
203            extra_tags: Vec::new(),
204        }
205    }
206
207    /// Set [`Self::name`].
208    #[must_use]
209    pub fn name(mut self, name: impl Into<String>) -> Self {
210        self.name = Some(name.into());
211        self
212    }
213
214    /// Set [`Self::description`].
215    #[must_use]
216    pub fn description(mut self, description: impl Into<String>) -> Self {
217        self.description = Some(description.into());
218        self
219    }
220
221    /// Set [`Self::image`].
222    #[must_use]
223    pub fn image(mut self, image: CommunityImage) -> Self {
224        self.image = Some(image);
225        self
226    }
227
228    /// Append one moderator.
229    #[must_use]
230    pub fn moderator(mut self, m: CommunityModerator) -> Self {
231        self.moderators.push(m);
232        self
233    }
234
235    /// Append one relay.
236    #[must_use]
237    pub fn relay(mut self, r: CommunityRelay) -> Self {
238        self.relays.push(r);
239        self
240    }
241
242    /// Append a passthrough tag (forward-compat).
243    #[must_use]
244    pub fn extra_tag(mut self, tag: Tag) -> Self {
245        self.extra_tags.push(tag);
246        self
247    }
248
249    /// Render to the tag list of a `kind: 34550` event.
250    #[must_use]
251    pub fn to_tags(&self) -> Vec<Tag> {
252        let mut tags: Vec<Tag> = Vec::with_capacity(
253            4 + self.moderators.len() + self.relays.len() + self.extra_tags.len(),
254        );
255        tags.push(Tag::d(&self.identifier));
256        if let Some(name) = &self.name {
257            tags.push(custom("name", [name.clone()]));
258        }
259        if let Some(desc) = &self.description {
260            tags.push(custom("description", [desc.clone()]));
261        }
262        if let Some(image) = &self.image {
263            let mut values: Vec<String> = Vec::with_capacity(2);
264            values.push(image.url.clone());
265            if let Some(dim) = image.dim {
266                values.push(dim.to_string());
267            }
268            tags.push(custom("image", values));
269        }
270        for m in &self.moderators {
271            let mut values: Vec<String> = Vec::with_capacity(4);
272            values.push(m.pubkey.to_hex());
273            values.push(
274                m.relay_hint
275                    .as_ref()
276                    .map(|r| r.as_str().to_owned())
277                    .unwrap_or_default(),
278            );
279            values.push("moderator".to_owned());
280            tags.push(letter(Alphabet::P, values));
281        }
282        for r in &self.relays {
283            let mut values: Vec<String> = Vec::with_capacity(2);
284            values.push(r.url.as_str().to_owned());
285            if let Some(marker) = r.marker.as_str() {
286                values.push(marker.to_owned());
287            }
288            tags.push(custom("relay", values));
289        }
290        for tag in &self.extra_tags {
291            tags.push(tag.clone());
292        }
293        tags
294    }
295
296    /// Parse a `kind: 34550` event back into a typed bundle.
297    ///
298    /// # Errors
299    ///
300    /// - [`CommunityError::WrongKind`] for any other kind.
301    /// - [`CommunityError::MissingIdentifier`] when no `d` tag.
302    /// - [`CommunityError::InvalidPublicKey`] /
303    ///   [`CommunityError::InvalidRelayUrl`] /
304    ///   [`CommunityError::InvalidImageDim`] for malformed values.
305    pub fn from_event(event: &Event) -> Result<Self, CommunityError> {
306        if event.kind != KIND_COMMUNITY_DEFINITION {
307            return Err(CommunityError::WrongKind(event.kind));
308        }
309        let identifier = identifier_value(&event.tags)
310            .ok_or(CommunityError::MissingIdentifier)?
311            .to_owned();
312        let mut def = Self::new(identifier);
313
314        for tag in &event.tags {
315            match tag.kind() {
316                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::D => {
317                    // Identifier already captured.
318                }
319                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P => {
320                    handle_p_tag(tag, &mut def)?;
321                }
322                TagKind::Custom(name) if name == "name" => {
323                    def.name = tag.get(1).map(str::to_owned);
324                }
325                TagKind::Custom(name) if name == "description" => {
326                    def.description = tag.get(1).map(str::to_owned);
327                }
328                TagKind::Custom(name) if name == "image" => {
329                    def.image = parse_image(tag)?;
330                }
331                TagKind::Custom(name) if name == "relay" => {
332                    def.relays.push(parse_relay(tag)?);
333                }
334                _ => def.extra_tags.push(tag.clone()),
335            }
336        }
337        Ok(def)
338    }
339
340    /// Build the community's addressable coordinate.
341    ///
342    /// `author` is the community owner's pubkey (the event creator).
343    #[must_use]
344    pub fn coordinate(&self, author: PublicKey) -> Coordinate {
345        Coordinate::new(KIND_COMMUNITY_DEFINITION, author, self.identifier.clone())
346    }
347}
348
349fn handle_p_tag(tag: &Tag, def: &mut CommunityDefinition) -> Result<(), CommunityError> {
350    if tag.get(3) == Some("moderator") {
351        def.moderators.push(parse_moderator(tag)?);
352    } else {
353        def.extra_tags.push(tag.clone());
354    }
355    Ok(())
356}
357
358fn parse_a_tag(tag: &Tag) -> Result<(Coordinate, Option<RelayUrl>), ApprovalError> {
359    let coord_str = tag.get(1).ok_or(ApprovalError::MalformedAddressTag)?;
360    let coord = Coordinate::parse(coord_str).map_err(ApprovalError::InvalidCoordinate)?;
361    let relay = tag_optional_relay(tag, 2)?;
362    Ok((coord, relay))
363}
364
365fn dispatch_a_tag(
366    parsed: (Coordinate, Option<RelayUrl>),
367    community: &mut Option<(Coordinate, Option<RelayUrl>)>,
368    address_target: &mut Option<(Coordinate, Option<RelayUrl>)>,
369) {
370    if parsed.0.kind == KIND_COMMUNITY_DEFINITION && community.is_none() {
371        *community = Some(parsed);
372    } else {
373        *address_target = Some(parsed);
374    }
375}
376
377fn parse_moderator(tag: &Tag) -> Result<CommunityModerator, CommunityError> {
378    let pk_hex = tag.get(1).ok_or(CommunityError::MalformedModerator)?;
379    let pubkey = PublicKey::parse(pk_hex).map_err(CommunityError::InvalidPublicKey)?;
380    let relay_hint = match tag.get(2) {
381        Some(s) if !s.is_empty() => {
382            Some(RelayUrl::parse(s).map_err(CommunityError::InvalidRelayUrl)?)
383        }
384        _ => None,
385    };
386    Ok(CommunityModerator { pubkey, relay_hint })
387}
388
389fn parse_image(tag: &Tag) -> Result<Option<CommunityImage>, CommunityError> {
390    let Some(url) = tag.get(1) else {
391        return Ok(None);
392    };
393    let mut image = CommunityImage::new(url.to_owned());
394    if let Some(dim_str) = tag.get(2)
395        && !dim_str.is_empty()
396    {
397        image.dim = Some(
398            dim_str
399                .parse::<ImageDimensions>()
400                .map_err(CommunityError::InvalidImageDim)?,
401        );
402    }
403    Ok(Some(image))
404}
405
406fn parse_relay(tag: &Tag) -> Result<CommunityRelay, CommunityError> {
407    let url_str = tag.get(1).ok_or(CommunityError::MalformedRelay)?;
408    let url = RelayUrl::parse(url_str).map_err(CommunityError::InvalidRelayUrl)?;
409    let marker = CommunityRelayMarker::parse(tag.get(2));
410    Ok(CommunityRelay { url, marker })
411}
412
413fn identifier_value(tags: &Tags) -> Option<&str> {
414    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
415    tags.find_first(&head).and_then(|tag| tag.get(1))
416}
417
418fn custom<I, S>(name: &str, args: I) -> Tag
419where
420    I: IntoIterator<Item = S>,
421    S: Into<String>,
422{
423    Tag::with(&TagKind::Custom(name.to_owned()), args)
424}
425
426fn letter<I, S>(alphabet: Alphabet, args: I) -> Tag
427where
428    I: IntoIterator<Item = S>,
429    S: Into<String>,
430{
431    let head = TagKind::single_letter(SingleLetterTag::lowercase(alphabet));
432    Tag::with(&head, args)
433}
434
435/// Pointer to the post being approved.
436///
437/// Spec §"Moderation" allows three forms:
438/// - `e`-tag only (regular events, or one specific replaceable
439///   version);
440/// - `a`-tag only (replaceable events at any version);
441/// - both, so clients can show "the version at the time of approval".
442#[derive(Debug, Clone, PartialEq, Eq)]
443#[non_exhaustive]
444pub enum ApprovalTarget {
445    /// Approval points at a specific event id.
446    Event {
447        /// Event id of the post.
448        id: EventId,
449        /// Optional relay hint.
450        relay_hint: Option<RelayUrl>,
451    },
452    /// Approval points at a replaceable event coordinate.
453    Address {
454        /// Coordinate of the post.
455        coordinate: Coordinate,
456        /// Optional relay hint.
457        relay_hint: Option<RelayUrl>,
458    },
459    /// Approval pins a specific version (`e`) of an addressable
460    /// post (`a`).
461    Both {
462        /// Event id of the snapshot version.
463        id: EventId,
464        /// Coordinate of the addressable post.
465        coordinate: Coordinate,
466        /// Optional relay hint reused on both tags.
467        relay_hint: Option<RelayUrl>,
468    },
469}
470
471/// Typed bundle for a `kind: 4550` post approval.
472#[derive(Debug, Clone, PartialEq, Eq)]
473pub struct PostApproval {
474    /// Coordinate of the community whose moderators are approving.
475    pub community: Coordinate,
476    /// Optional relay hint for the community `a` tag.
477    pub community_relay: Option<RelayUrl>,
478    /// Pointer to the approved post.
479    pub target: ApprovalTarget,
480    /// Original post's kind (`k` tag).
481    pub post_kind: Kind,
482    /// Original post's author (`p` tag).
483    pub post_author: PublicKey,
484    /// Optional relay hint for the post-author `p` tag.
485    pub post_author_relay: Option<RelayUrl>,
486    /// JSON-encoded original post per spec §"Moderation"
487    /// recommendation.
488    pub original_event_json: String,
489}
490
491impl PostApproval {
492    /// Construct a typed approval.
493    #[must_use]
494    pub fn new(
495        community: Coordinate,
496        target: ApprovalTarget,
497        post_kind: Kind,
498        post_author: PublicKey,
499        original_event_json: impl Into<String>,
500    ) -> Self {
501        Self {
502            community,
503            community_relay: None,
504            target,
505            post_kind,
506            post_author,
507            post_author_relay: None,
508            original_event_json: original_event_json.into(),
509        }
510    }
511
512    /// Add a relay hint to the community `a` tag.
513    #[must_use]
514    pub fn community_relay(mut self, relay: RelayUrl) -> Self {
515        self.community_relay = Some(relay);
516        self
517    }
518
519    /// Add a relay hint to the post-author `p` tag.
520    #[must_use]
521    pub fn post_author_relay(mut self, relay: RelayUrl) -> Self {
522        self.post_author_relay = Some(relay);
523        self
524    }
525
526    /// Render to the tag list of a `kind: 4550` event.
527    #[must_use]
528    pub fn to_tags(&self) -> Vec<Tag> {
529        let mut tags: Vec<Tag> = Vec::with_capacity(5);
530        // Community a-tag (lowercase per spec example).
531        let mut a_values: Vec<String> = Vec::with_capacity(2);
532        a_values.push(self.community.to_wire());
533        if let Some(relay) = &self.community_relay {
534            a_values.push(relay.as_str().to_owned());
535        }
536        tags.push(letter(Alphabet::A, a_values));
537
538        // Approval target tags.
539        match &self.target {
540            ApprovalTarget::Event { id, relay_hint } => {
541                tags.push(event_tag(*id, relay_hint.as_ref()));
542            }
543            ApprovalTarget::Address {
544                coordinate,
545                relay_hint,
546            } => {
547                tags.push(address_tag(coordinate, relay_hint.as_ref()));
548            }
549            ApprovalTarget::Both {
550                id,
551                coordinate,
552                relay_hint,
553            } => {
554                tags.push(event_tag(*id, relay_hint.as_ref()));
555                tags.push(address_tag(coordinate, relay_hint.as_ref()));
556            }
557        }
558
559        // Post author p-tag.
560        let mut p_values: Vec<String> = Vec::with_capacity(2);
561        p_values.push(self.post_author.to_hex());
562        if let Some(relay) = &self.post_author_relay {
563            p_values.push(relay.as_str().to_owned());
564        }
565        tags.push(letter(Alphabet::P, p_values));
566
567        // Post kind k-tag.
568        tags.push(letter(Alphabet::K, [self.post_kind.as_u16().to_string()]));
569        tags
570    }
571
572    /// Parse a `kind: 4550` event back into a typed bundle.
573    ///
574    /// # Errors
575    ///
576    /// Forwarded from [`Coordinate::parse`] / [`PublicKey::parse`] /
577    /// [`RelayUrl::parse`] when the corresponding tag column is
578    /// malformed, plus the dedicated `Missing*` errors when a
579    /// required tag is absent.
580    pub fn from_event(event: &Event) -> Result<Self, ApprovalError> {
581        if event.kind != KIND_POST_APPROVAL {
582            return Err(ApprovalError::WrongKind(event.kind));
583        }
584
585        let mut community: Option<(Coordinate, Option<RelayUrl>)> = None;
586        let mut event_target: Option<(EventId, Option<RelayUrl>)> = None;
587        let mut address_target: Option<(Coordinate, Option<RelayUrl>)> = None;
588        let mut post_author: Option<(PublicKey, Option<RelayUrl>)> = None;
589        let mut post_kind: Option<Kind> = None;
590
591        for tag in &event.tags {
592            match tag.kind() {
593                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::A => {
594                    dispatch_a_tag(parse_a_tag(tag)?, &mut community, &mut address_target);
595                }
596                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::E => {
597                    let id_hex = tag.get(1).ok_or(ApprovalError::MalformedEventTag)?;
598                    let id = EventId::parse(id_hex).map_err(ApprovalError::InvalidEventId)?;
599                    let relay = tag_optional_relay(tag, 2)?;
600                    event_target = Some((id, relay));
601                }
602                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P => {
603                    let pk_hex = tag.get(1).ok_or(ApprovalError::MalformedAuthorTag)?;
604                    let pk = PublicKey::parse(pk_hex).map_err(ApprovalError::InvalidPublicKey)?;
605                    let relay = tag_optional_relay(tag, 2)?;
606                    post_author = Some((pk, relay));
607                }
608                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::K => {
609                    let k_str = tag.get(1).ok_or(ApprovalError::MalformedKindTag)?;
610                    let raw: u16 = k_str.parse().map_err(|_| ApprovalError::MalformedKindTag)?;
611                    post_kind = Some(Kind::new(raw));
612                }
613                _ => {}
614            }
615        }
616
617        let (community, community_relay) = community.ok_or(ApprovalError::MissingCommunity)?;
618        let target = match (event_target, address_target) {
619            (Some((id, r1)), Some((coordinate, r2))) => ApprovalTarget::Both {
620                id,
621                coordinate,
622                relay_hint: r1.or(r2),
623            },
624            (Some((id, relay_hint)), None) => ApprovalTarget::Event { id, relay_hint },
625            (None, Some((coordinate, relay_hint))) => ApprovalTarget::Address {
626                coordinate,
627                relay_hint,
628            },
629            (None, None) => return Err(ApprovalError::MissingTarget),
630        };
631        let (post_author, post_author_relay) =
632            post_author.ok_or(ApprovalError::MissingPostAuthor)?;
633        let post_kind = post_kind.ok_or(ApprovalError::MissingPostKind)?;
634
635        Ok(Self {
636            community,
637            community_relay,
638            target,
639            post_kind,
640            post_author,
641            post_author_relay,
642            original_event_json: event.content.clone(),
643        })
644    }
645}
646
647fn event_tag(id: EventId, relay: Option<&RelayUrl>) -> Tag {
648    let mut values: Vec<String> = Vec::with_capacity(2);
649    values.push(id.to_hex());
650    if let Some(r) = relay {
651        values.push(r.as_str().to_owned());
652    }
653    letter(Alphabet::E, values)
654}
655
656fn address_tag(coordinate: &Coordinate, relay: Option<&RelayUrl>) -> Tag {
657    let mut values: Vec<String> = Vec::with_capacity(2);
658    values.push(coordinate.to_wire());
659    if let Some(r) = relay {
660        values.push(r.as_str().to_owned());
661    }
662    letter(Alphabet::A, values)
663}
664
665fn tag_optional_relay(tag: &Tag, idx: usize) -> Result<Option<RelayUrl>, ApprovalError> {
666    match tag.get(idx) {
667        Some(s) if !s.is_empty() => Ok(Some(
668            RelayUrl::parse(s).map_err(ApprovalError::InvalidRelayUrl)?,
669        )),
670        _ => Ok(None),
671    }
672}
673
674/// Errors raised while parsing a [`CommunityDefinition`].
675#[derive(Debug, Error)]
676#[non_exhaustive]
677pub enum CommunityError {
678    /// Event kind was not 34550.
679    #[error("expected kind 34550 (community definition), got {}", .0.as_u16())]
680    WrongKind(Kind),
681    /// `d` tag was absent.
682    #[error("NIP-72 community must carry a `d` tag")]
683    MissingIdentifier,
684    /// A `p` moderator tag was missing the pubkey column.
685    #[error("malformed `p` moderator tag (missing pubkey)")]
686    MalformedModerator,
687    /// A `relay` tag was missing its URL column.
688    #[error("malformed `relay` tag (missing URL)")]
689    MalformedRelay,
690    /// A pubkey value did not parse.
691    #[error("invalid public key: {0}")]
692    InvalidPublicKey(#[source] PublicKeyError),
693    /// A relay URL did not parse.
694    #[error("invalid relay URL: {0}")]
695    InvalidRelayUrl(#[source] RelayUrlError),
696    /// `image[2]` failed to parse as [`ImageDimensions`] via its
697    /// [`FromStr`](std::str::FromStr) impl.
698    #[error("invalid image dimensions: {0}")]
699    InvalidImageDim(#[source] ImageError),
700}
701
702/// Errors raised while parsing a [`PostApproval`].
703#[derive(Debug, Error)]
704#[non_exhaustive]
705pub enum ApprovalError {
706    /// Event kind was not 4550.
707    #[error("expected kind 4550 (post approval), got {}", .0.as_u16())]
708    WrongKind(Kind),
709    /// No community `a` tag was present.
710    #[error("NIP-72 approval must carry a community `a` tag")]
711    MissingCommunity,
712    /// No `e`/`a` target tag was present.
713    #[error("NIP-72 approval must carry an `e` and/or `a` post tag")]
714    MissingTarget,
715    /// No `p` author tag was present.
716    #[error("NIP-72 approval must carry a `p` author tag")]
717    MissingPostAuthor,
718    /// No `k` kind tag was present.
719    #[error("NIP-72 approval must carry a `k` kind tag")]
720    MissingPostKind,
721    /// `e` tag value was missing.
722    #[error("malformed `e` post tag (missing event id)")]
723    MalformedEventTag,
724    /// `a` tag value was missing.
725    #[error("malformed `a` tag (missing coordinate)")]
726    MalformedAddressTag,
727    /// `p` tag value was missing.
728    #[error("malformed `p` tag (missing author)")]
729    MalformedAuthorTag,
730    /// `k` tag was malformed.
731    #[error("malformed `k` tag (must be unsigned 16-bit kind number)")]
732    MalformedKindTag,
733    /// Coordinate string was not parseable.
734    #[error("invalid coordinate: {0}")]
735    InvalidCoordinate(#[source] CoordinateError),
736    /// Event id hex was not parseable.
737    #[error("invalid event id: {0}")]
738    InvalidEventId(#[source] EventIdError),
739    /// Pubkey hex was not parseable.
740    #[error("invalid public key: {0}")]
741    InvalidPublicKey(#[source] PublicKeyError),
742    /// Relay URL was not parseable.
743    #[error("invalid relay URL: {0}")]
744    InvalidRelayUrl(#[source] RelayUrlError),
745}
746
747impl EventBuilder {
748    /// Author a NIP-72 community definition (`kind: 34550`).
749    #[must_use]
750    pub fn community_definition(definition: &CommunityDefinition) -> Self {
751        let mut builder = Self::new(KIND_COMMUNITY_DEFINITION, "");
752        for tag in definition.to_tags() {
753            builder = builder.tag(tag);
754        }
755        builder
756    }
757
758    /// Author a NIP-72 post approval (`kind: 4550`).
759    #[must_use]
760    pub fn community_post_approval(approval: &PostApproval) -> Self {
761        let mut builder = Self::new(KIND_POST_APPROVAL, approval.original_event_json.clone());
762        for tag in approval.to_tags() {
763            builder = builder.tag(tag);
764        }
765        builder
766    }
767
768    /// Author a top-level community post (`kind: 1111` per NIP-22
769    /// + NIP-72 §"Top-level posts").
770    ///
771    /// Internally builds a [`Comment`] whose root *and* parent are
772    /// the community coordinate.
773    #[must_use]
774    pub fn community_top_level_post(
775        community: Coordinate,
776        community_relay: Option<RelayUrl>,
777        community_author: PublicKey,
778        content: impl Into<String>,
779    ) -> Self {
780        let scope = CommentScope::Address {
781            coordinate: community,
782            relay_hint: community_relay,
783        };
784        let comment = Comment::top_level(scope, content)
785            .with_root_kind(KIND_COMMUNITY_DEFINITION)
786            .with_root_author(community_author);
787        Self::comment(&comment)
788    }
789
790    /// Author a nested community reply (`kind: 1111` per NIP-22
791    /// + NIP-72 §"Nested replies").
792    ///
793    /// `parent_post` is the parent kind 1111 reply (or another
794    /// post). The community coordinate stays at the *root* scope
795    /// while `parent_post` lives at the *parent* scope.
796    #[must_use]
797    #[allow(
798        clippy::too_many_arguments,
799        reason = "every argument maps directly to a NIP-72 §\"Nested replies\" tag column"
800    )]
801    pub fn community_nested_reply(
802        community: Coordinate,
803        community_relay: Option<RelayUrl>,
804        community_author: PublicKey,
805        parent_post: EventId,
806        parent_relay: Option<RelayUrl>,
807        parent_kind: Kind,
808        parent_author: PublicKey,
809        content: impl Into<String>,
810    ) -> Self {
811        let root = CommentScope::Address {
812            coordinate: community,
813            relay_hint: community_relay,
814        };
815        let parent = CommentScope::Event {
816            id: parent_post,
817            relay_hint: parent_relay,
818        };
819        let comment = Comment::top_level(root, content)
820            .with_root_kind(KIND_COMMUNITY_DEFINITION)
821            .with_root_author(community_author)
822            .with_parent(parent)
823            .with_parent_kind(parent_kind)
824            .with_parent_author(parent_author);
825        Self::comment(&comment)
826    }
827}
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832    use crate::Keys;
833
834    fn keys() -> Keys {
835        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
836    }
837
838    fn other_keys() -> Keys {
839        Keys::parse("0000000000000000000000000000000000000000000000000000000000000005").unwrap()
840    }
841
842    fn fixture_definition() -> CommunityDefinition {
843        let owner = *keys().public_key();
844        let _ = owner;
845        let mod1 = CommunityModerator::new(*keys().public_key())
846            .relay_hint(RelayUrl::parse("wss://moderator-relay.example/").unwrap());
847        let mod2 = CommunityModerator::new(*other_keys().public_key());
848        let auth_relay = CommunityRelay::new(
849            RelayUrl::parse("wss://author.example/").unwrap(),
850            CommunityRelayMarker::Author,
851        );
852        let req_relay = CommunityRelay::new(
853            RelayUrl::parse("wss://requests.example/").unwrap(),
854            CommunityRelayMarker::Requests,
855        );
856        let plain_relay =
857            CommunityRelay::unmarked(RelayUrl::parse("wss://plain.example/").unwrap());
858        CommunityDefinition::new("rust-nostr")
859            .name("Rust Nostr")
860            .description("Implementations and discussion")
861            .image(
862                CommunityImage::new("https://example.com/logo.png")
863                    .dim(ImageDimensions::new(800, 600).unwrap()),
864            )
865            .moderator(mod1)
866            .moderator(mod2)
867            .relay(auth_relay)
868            .relay(req_relay)
869            .relay(plain_relay)
870    }
871
872    #[test]
873    fn community_definition_round_trips_through_event() {
874        let def = fixture_definition();
875        let event = EventBuilder::community_definition(&def)
876            .sign_with_keys(&keys())
877            .unwrap();
878        assert_eq!(event.kind, KIND_COMMUNITY_DEFINITION);
879        let parsed = CommunityDefinition::from_event(&event).unwrap();
880        assert_eq!(parsed, def);
881    }
882
883    #[test]
884    fn community_definition_rejects_wrong_kind() {
885        let event = EventBuilder::text_note("nope")
886            .sign_with_keys(&keys())
887            .unwrap();
888        assert!(matches!(
889            CommunityDefinition::from_event(&event),
890            Err(CommunityError::WrongKind(_))
891        ));
892    }
893
894    #[test]
895    fn community_definition_requires_d_tag() {
896        let event = EventBuilder::new(KIND_COMMUNITY_DEFINITION, "")
897            .sign_with_keys(&keys())
898            .unwrap();
899        assert!(matches!(
900            CommunityDefinition::from_event(&event),
901            Err(CommunityError::MissingIdentifier)
902        ));
903    }
904
905    #[test]
906    fn community_relay_marker_round_trips_unknown() {
907        let m = CommunityRelayMarker::parse(Some("custom-marker"));
908        assert_eq!(m, CommunityRelayMarker::Other("custom-marker".to_owned()));
909        assert_eq!(m.as_str(), Some("custom-marker"));
910    }
911
912    #[test]
913    fn coordinate_helper_uses_owner_pubkey() {
914        let def = CommunityDefinition::new("slug");
915        let coord = def.coordinate(*keys().public_key());
916        assert_eq!(coord.kind, KIND_COMMUNITY_DEFINITION);
917        assert_eq!(coord.identifier, "slug");
918        assert_eq!(coord.author, *keys().public_key());
919    }
920
921    #[test]
922    fn approval_with_event_target_round_trips() {
923        let community = Coordinate::new(KIND_COMMUNITY_DEFINITION, *keys().public_key(), "slug");
924        let id = EventId::from_byte_array([0x77; 32]);
925        let approval = PostApproval::new(
926            community,
927            ApprovalTarget::Event {
928                id,
929                relay_hint: None,
930            },
931            Kind::TEXT_NOTE,
932            *other_keys().public_key(),
933            r#"{"id":"77...","kind":1}"#,
934        );
935        let event = EventBuilder::community_post_approval(&approval)
936            .sign_with_keys(&keys())
937            .unwrap();
938        assert_eq!(event.kind, KIND_POST_APPROVAL);
939        let parsed = PostApproval::from_event(&event).unwrap();
940        assert_eq!(parsed, approval);
941    }
942
943    #[test]
944    fn approval_with_address_target_round_trips() {
945        let community = Coordinate::new(KIND_COMMUNITY_DEFINITION, *keys().public_key(), "slug");
946        let target_coord = Coordinate::new(
947            Kind::LONG_FORM_TEXT_NOTE,
948            *other_keys().public_key(),
949            "post-1",
950        );
951        let approval = PostApproval::new(
952            community,
953            ApprovalTarget::Address {
954                coordinate: target_coord,
955                relay_hint: None,
956            },
957            Kind::LONG_FORM_TEXT_NOTE,
958            *other_keys().public_key(),
959            "{}",
960        );
961        let event = EventBuilder::community_post_approval(&approval)
962            .sign_with_keys(&keys())
963            .unwrap();
964        let parsed = PostApproval::from_event(&event).unwrap();
965        assert_eq!(parsed, approval);
966    }
967
968    #[test]
969    fn approval_with_both_targets_round_trips() {
970        let community = Coordinate::new(KIND_COMMUNITY_DEFINITION, *keys().public_key(), "slug");
971        let target_coord = Coordinate::new(
972            Kind::LONG_FORM_TEXT_NOTE,
973            *other_keys().public_key(),
974            "post-1",
975        );
976        let target_id = EventId::from_byte_array([0xab; 32]);
977        let approval = PostApproval::new(
978            community,
979            ApprovalTarget::Both {
980                id: target_id,
981                coordinate: target_coord,
982                relay_hint: None,
983            },
984            Kind::LONG_FORM_TEXT_NOTE,
985            *other_keys().public_key(),
986            "{}",
987        );
988        let event = EventBuilder::community_post_approval(&approval)
989            .sign_with_keys(&keys())
990            .unwrap();
991        let parsed = PostApproval::from_event(&event).unwrap();
992        assert_eq!(parsed, approval);
993    }
994
995    #[test]
996    fn approval_rejects_wrong_kind() {
997        let event = EventBuilder::text_note("nope")
998            .sign_with_keys(&keys())
999            .unwrap();
1000        assert!(matches!(
1001            PostApproval::from_event(&event),
1002            Err(ApprovalError::WrongKind(_))
1003        ));
1004    }
1005
1006    #[test]
1007    fn community_top_level_post_uses_kind_1111() {
1008        let community = Coordinate::new(KIND_COMMUNITY_DEFINITION, *keys().public_key(), "slug");
1009        let event =
1010            EventBuilder::community_top_level_post(community, None, *keys().public_key(), "hi")
1011                .sign_with_keys(&other_keys())
1012                .unwrap();
1013        assert_eq!(event.kind, Kind::new(1111));
1014    }
1015
1016    #[test]
1017    fn community_nested_reply_uses_kind_1111() {
1018        let community = Coordinate::new(KIND_COMMUNITY_DEFINITION, *keys().public_key(), "slug");
1019        let parent = EventId::from_byte_array([0xab; 32]);
1020        let event = EventBuilder::community_nested_reply(
1021            community,
1022            None,
1023            *keys().public_key(),
1024            parent,
1025            None,
1026            Kind::new(1111),
1027            *other_keys().public_key(),
1028            "agreed",
1029        )
1030        .sign_with_keys(&other_keys())
1031        .unwrap();
1032        assert_eq!(event.kind, Kind::new(1111));
1033    }
1034}