Skip to main content

nula_core/nips/
nip28.rs

1//! [NIP-28] Public Chat.
2//!
3//! Five event kinds wire up Telegram-style public chat over relays:
4//!
5//! | Kind | Purpose                      | Payload                                                 |
6//! |------|------------------------------|---------------------------------------------------------|
7//! | 40   | [Channel creation]           | JSON metadata in `.content`                             |
8//! | 41   | [Channel metadata update]    | JSON metadata in `.content`, `e` tag points at the kind 40 |
9//! | 42   | [Channel message]            | Text in `.content`, NIP-10-marked `e`/`p` tags          |
10//! | 43   | [Hide message] (per-viewer)  | Optional reason JSON, `e` tag points at the kind 42     |
11//! | 44   | [Mute user] (per-viewer)     | Optional reason JSON, `p` tag points at the muted user  |
12//!
13//! [Channel creation]: https://github.com/nostr-protocol/nips/blob/master/28.md#kind-40-create-channel
14//! [Channel metadata update]: https://github.com/nostr-protocol/nips/blob/master/28.md#kind-41-set-channel-metadata
15//! [Channel message]: https://github.com/nostr-protocol/nips/blob/master/28.md#kind-42-create-channel-message
16//! [Hide message]: https://github.com/nostr-protocol/nips/blob/master/28.md#kind-43-hide-message
17//! [Mute user]: https://github.com/nostr-protocol/nips/blob/master/28.md#kind-44-mute-user
18//!
19//! # Why a typed module
20//!
21//! Upstream `rust-nostr` did not ship a dedicated NIP-28 module; the
22//! channel builders are scattered across `event/builder.rs` and
23//! callers re-parse the JSON metadata themselves. We bundle the
24//! whole flow in one place:
25//!
26//! - [`ChannelMetadata`] — typed bundle for the kind 40 / 41 JSON
27//!   `.content` body. `name`, `about`, `picture`, and `relays` are
28//!   first-class fields; everything else round-trips through a
29//!   `serde_json::Map` so future per-app metadata never gets lost.
30//! - [`HideReason`] — typed bundle for the optional `.content` JSON
31//!   used by kinds 43 and 44. Spec lists `reason` as the canonical
32//!   key but explicitly leaves the body open-ended.
33//! - [`EventBuilder`] gains six builders covering the full create /
34//!   update / message-root / message-reply / hide / mute flow with
35//!   NIP-10 marker tags applied per spec §"Kind 42".
36//!
37//! [NIP-28]: https://github.com/nostr-protocol/nips/blob/master/28.md
38
39use indexmap::IndexMap;
40use serde::{Deserialize, Serialize};
41use thiserror::Error;
42
43use crate::event::{Alphabet, Event, EventBuilder, EventId, Kind, SingleLetterTag, Tag, TagKind};
44use crate::key::PublicKey;
45use crate::types::{RelayUrl, RelayUrlError};
46
47/// `kind: 40` — channel creation.
48pub const KIND_CHANNEL_CREATE: Kind = Kind::CHANNEL_CREATION;
49/// `kind: 41` — channel metadata update.
50pub const KIND_CHANNEL_METADATA: Kind = Kind::CHANNEL_METADATA;
51/// `kind: 42` — channel chat message.
52pub const KIND_CHANNEL_MESSAGE: Kind = Kind::CHANNEL_MESSAGE;
53/// `kind: 43` — channel hide-message moderation.
54pub const KIND_CHANNEL_HIDE_MESSAGE: Kind = Kind::CHANNEL_HIDE_MESSAGE;
55/// `kind: 44` — channel mute-user moderation.
56pub const KIND_CHANNEL_MUTE_USER: Kind = Kind::CHANNEL_MUTE_USER;
57
58/// JSON `.content` body for kinds 40 and 41.
59///
60/// The four spec-named fields (`name`, `about`, `picture`,
61/// `relays`) are first-class. Any other JSON property the
62/// originating app stamps on the metadata object survives via
63/// [`Self::extra`].
64#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
65pub struct ChannelMetadata {
66    /// Channel name (kind 40 / 41 §"name").
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub name: Option<String>,
69    /// Long-form description (kind 40 / 41 §"about").
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub about: Option<String>,
72    /// URL of channel picture. Spec leaves the value unconstrained
73    /// so we keep it as `String`; callers that need URL validation
74    /// should round-trip through [`crate::Url`] themselves.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub picture: Option<String>,
77    /// Relays where the channel events are broadcast.
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub relays: Vec<RelayUrl>,
80    /// Forward-compatible passthrough of every other JSON property.
81    #[serde(flatten)]
82    pub extra: IndexMap<String, serde_json::Value>,
83}
84
85impl ChannelMetadata {
86    /// Construct an empty bundle.
87    #[must_use]
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    /// Set [`Self::name`].
93    #[must_use]
94    pub fn name(mut self, name: impl Into<String>) -> Self {
95        self.name = Some(name.into());
96        self
97    }
98
99    /// Set [`Self::about`].
100    #[must_use]
101    pub fn about(mut self, about: impl Into<String>) -> Self {
102        self.about = Some(about.into());
103        self
104    }
105
106    /// Set [`Self::picture`].
107    #[must_use]
108    pub fn picture(mut self, picture: impl Into<String>) -> Self {
109        self.picture = Some(picture.into());
110        self
111    }
112
113    /// Append a relay hint.
114    #[must_use]
115    pub fn relay(mut self, relay: RelayUrl) -> Self {
116        self.relays.push(relay);
117        self
118    }
119
120    /// Append several relay hints.
121    #[must_use]
122    pub fn relays<I>(mut self, relays: I) -> Self
123    where
124        I: IntoIterator<Item = RelayUrl>,
125    {
126        self.relays.extend(relays);
127        self
128    }
129
130    /// Render to JSON ready for use as `.content`.
131    ///
132    /// # Errors
133    ///
134    /// Forwarded from `serde_json` — the only failure modes are
135    /// non-`UTF-8` strings inside [`Self::extra`], which Rust strings
136    /// cannot represent in the first place, or numeric overflow in
137    /// callers that put `serde_json::Number::from_f64(f64::NAN)` in
138    /// `extra`. Both are degenerate.
139    pub fn to_json(&self) -> Result<String, serde_json::Error> {
140        serde_json::to_string(self)
141    }
142
143    /// Parse a JSON `.content` string into the typed bundle.
144    ///
145    /// # Errors
146    ///
147    /// - [`ChannelMetadataError::InvalidJson`] when the input is not
148    ///   a JSON object.
149    /// - [`ChannelMetadataError::InvalidRelayUrl`] when a `relays[i]`
150    ///   string fails [`RelayUrl::parse`].
151    pub fn from_json(json: &str) -> Result<Self, ChannelMetadataError> {
152        let raw: serde_json::Value =
153            serde_json::from_str(json).map_err(ChannelMetadataError::InvalidJson)?;
154        let serde_json::Value::Object(mut map) = raw else {
155            return Err(ChannelMetadataError::NotAnObject);
156        };
157
158        let mut metadata = Self::default();
159        if let Some(value) = map.remove("name") {
160            metadata.name = string_field(value, "name")?;
161        }
162        if let Some(value) = map.remove("about") {
163            metadata.about = string_field(value, "about")?;
164        }
165        if let Some(value) = map.remove("picture") {
166            metadata.picture = string_field(value, "picture")?;
167        }
168        if let Some(value) = map.remove("relays") {
169            metadata.relays = relays_field(value)?;
170        }
171        for (key, value) in map {
172            metadata.extra.insert(key, value);
173        }
174        Ok(metadata)
175    }
176}
177
178fn string_field(
179    value: serde_json::Value,
180    key: &'static str,
181) -> Result<Option<String>, ChannelMetadataError> {
182    match value {
183        serde_json::Value::Null => Ok(None),
184        serde_json::Value::String(s) => Ok(Some(s)),
185        other => Err(ChannelMetadataError::InvalidStringField {
186            key,
187            actual: type_name(&other).to_owned(),
188        }),
189    }
190}
191
192fn relays_field(value: serde_json::Value) -> Result<Vec<RelayUrl>, ChannelMetadataError> {
193    let serde_json::Value::Array(arr) = value else {
194        return Err(ChannelMetadataError::InvalidRelaysField);
195    };
196    let mut relays: Vec<RelayUrl> = Vec::with_capacity(arr.len());
197    for item in arr {
198        let serde_json::Value::String(url) = item else {
199            return Err(ChannelMetadataError::InvalidRelaysField);
200        };
201        relays.push(RelayUrl::parse(&url).map_err(ChannelMetadataError::InvalidRelayUrl)?);
202    }
203    Ok(relays)
204}
205
206const fn type_name(value: &serde_json::Value) -> &'static str {
207    match value {
208        serde_json::Value::Null => "null",
209        serde_json::Value::Bool(_) => "bool",
210        serde_json::Value::Number(_) => "number",
211        serde_json::Value::String(_) => "string",
212        serde_json::Value::Array(_) => "array",
213        serde_json::Value::Object(_) => "object",
214    }
215}
216
217/// JSON `.content` body for kinds 43 and 44.
218///
219/// Spec lists `reason` as the canonical key but says other
220/// metadata is permitted; we keep the JSON flat so any
221/// app-specific moderation rationale survives the round-trip.
222#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
223pub struct HideReason {
224    /// Free-form reason string (spec §"Kind 43" example).
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub reason: Option<String>,
227    /// Forward-compatible passthrough.
228    #[serde(flatten)]
229    pub extra: IndexMap<String, serde_json::Value>,
230}
231
232impl HideReason {
233    /// Construct an empty reason bundle.
234    #[must_use]
235    pub fn new() -> Self {
236        Self::default()
237    }
238
239    /// Set [`Self::reason`].
240    #[must_use]
241    pub fn reason(mut self, reason: impl Into<String>) -> Self {
242        self.reason = Some(reason.into());
243        self
244    }
245
246    /// Render to JSON ready for use as `.content`.
247    ///
248    /// Returns the empty string when both `reason` and `extra` are
249    /// empty, matching the spec's "may optionally include metadata"
250    /// language.
251    #[must_use]
252    pub fn to_json(&self) -> String {
253        if self.reason.is_none() && self.extra.is_empty() {
254            return String::new();
255        }
256        serde_json::to_string(self).unwrap_or_default()
257    }
258
259    /// Parse a JSON `.content` string into the typed bundle.
260    ///
261    /// An empty input yields a default-constructed bundle.
262    ///
263    /// # Errors
264    ///
265    /// - [`ChannelMetadataError::InvalidJson`] when the input is not
266    ///   parseable JSON.
267    pub fn from_json(json: &str) -> Result<Self, ChannelMetadataError> {
268        if json.trim().is_empty() {
269            return Ok(Self::default());
270        }
271        serde_json::from_str(json).map_err(ChannelMetadataError::InvalidJson)
272    }
273}
274
275/// Errors raised while parsing channel metadata JSON or hide-reason JSON.
276#[derive(Debug, Error)]
277#[non_exhaustive]
278pub enum ChannelMetadataError {
279    /// The `.content` was not parseable JSON.
280    #[error("invalid JSON: {0}")]
281    InvalidJson(#[source] serde_json::Error),
282    /// The JSON was a valid value but not an object.
283    #[error("expected a JSON object at the top level")]
284    NotAnObject,
285    /// A spec-named string field was something other than a string.
286    #[error("`{key}` must be a JSON string, got {actual}")]
287    InvalidStringField {
288        /// Field name (`name` / `about` / `picture`).
289        key: &'static str,
290        /// Actual JSON type encountered.
291        actual: String,
292    },
293    /// `relays` was not an array of strings.
294    #[error("`relays` must be a JSON array of strings")]
295    InvalidRelaysField,
296    /// One of the `relays[i]` strings failed [`RelayUrl::parse`].
297    #[error("invalid relay URL: {0}")]
298    InvalidRelayUrl(#[source] RelayUrlError),
299}
300
301impl EventBuilder {
302    /// Author a NIP-28 channel creation event (`kind: 40`).
303    ///
304    /// # Errors
305    ///
306    /// Forwarded from [`ChannelMetadata::to_json`].
307    pub fn channel_create(metadata: &ChannelMetadata) -> Result<Self, serde_json::Error> {
308        let json = metadata.to_json()?;
309        Ok(Self::new(KIND_CHANNEL_CREATE, json))
310    }
311
312    /// Author a NIP-28 channel metadata update (`kind: 41`).
313    ///
314    /// `channel` is the kind 40 event id; `relay` is an optional
315    /// recommended-relay hint placed in the `e` tag per NIP-10.
316    ///
317    /// # Errors
318    ///
319    /// Forwarded from [`ChannelMetadata::to_json`].
320    pub fn channel_metadata_update(
321        metadata: &ChannelMetadata,
322        channel: EventId,
323        relay: Option<&RelayUrl>,
324    ) -> Result<Self, serde_json::Error> {
325        let json = metadata.to_json()?;
326        Ok(Self::new(KIND_CHANNEL_METADATA, json).tag(channel_root_tag(channel, relay)))
327    }
328
329    /// Author a root channel message (`kind: 42` with a single `e`
330    /// tag marked `"root"`).
331    #[must_use]
332    pub fn channel_message_root(
333        channel: EventId,
334        relay: Option<&RelayUrl>,
335        content: impl Into<String>,
336    ) -> Self {
337        Self::new(KIND_CHANNEL_MESSAGE, content).tag(channel_root_tag(channel, relay))
338    }
339
340    /// Author a reply channel message (`kind: 42` with a `"root"`
341    /// tag pointing at the channel and a `"reply"` tag pointing at
342    /// the parent message; a `p` tag references the replied-to
343    /// author).
344    #[must_use]
345    pub fn channel_message_reply(
346        channel: EventId,
347        channel_relay: Option<&RelayUrl>,
348        parent_message: EventId,
349        parent_relay: Option<&RelayUrl>,
350        parent_author: PublicKey,
351        author_relay: Option<&RelayUrl>,
352        content: impl Into<String>,
353    ) -> Self {
354        Self::new(KIND_CHANNEL_MESSAGE, content)
355            .tag(channel_root_tag(channel, channel_relay))
356            .tag(channel_reply_tag(parent_message, parent_relay))
357            .tag(channel_p_tag(parent_author, author_relay))
358    }
359
360    /// Author a hide-message moderation event (`kind: 43`).
361    ///
362    /// `reason` is optional; pass `None` for a bare hide signal.
363    #[must_use]
364    pub fn channel_hide_message(target: EventId, reason: Option<&HideReason>) -> Self {
365        let body = reason.map(HideReason::to_json).unwrap_or_default();
366        Self::new(KIND_CHANNEL_HIDE_MESSAGE, body).tag(channel_e_tag(target))
367    }
368
369    /// Author a mute-user moderation event (`kind: 44`).
370    ///
371    /// `reason` is optional.
372    #[must_use]
373    pub fn channel_mute_user(target: PublicKey, reason: Option<&HideReason>) -> Self {
374        let body = reason.map(HideReason::to_json).unwrap_or_default();
375        Self::new(KIND_CHANNEL_MUTE_USER, body).tag(channel_p_tag(target, None))
376    }
377}
378
379fn channel_root_tag(channel: EventId, relay: Option<&RelayUrl>) -> Tag {
380    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
381    let mut values: Vec<String> = Vec::with_capacity(4);
382    values.push(channel.to_hex());
383    values.push(relay.map(|r| r.as_str().to_owned()).unwrap_or_default());
384    values.push("root".to_owned());
385    Tag::with(&head, values)
386}
387
388fn channel_reply_tag(parent: EventId, relay: Option<&RelayUrl>) -> Tag {
389    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
390    let mut values: Vec<String> = Vec::with_capacity(4);
391    values.push(parent.to_hex());
392    values.push(relay.map(|r| r.as_str().to_owned()).unwrap_or_default());
393    values.push("reply".to_owned());
394    Tag::with(&head, values)
395}
396
397fn channel_e_tag(target: EventId) -> Tag {
398    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
399    Tag::with(&head, [target.to_hex()])
400}
401
402fn channel_p_tag(target: PublicKey, relay: Option<&RelayUrl>) -> Tag {
403    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
404    let mut values: Vec<String> = Vec::with_capacity(2);
405    values.push(target.to_hex());
406    if let Some(relay) = relay {
407        values.push(relay.as_str().to_owned());
408    }
409    Tag::with(&head, values)
410}
411
412/// Look up the channel id (`e`-tag with `"root"` marker) on a
413/// kind-41/42 event. Returns `None` when no such tag exists.
414#[must_use]
415pub fn channel_root_id(event: &Event) -> Option<EventId> {
416    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
417    for tag in event.tags.find_all(&head) {
418        if tag.get(3) == Some("root")
419            && let Some(id_hex) = tag.get(1)
420            && let Ok(id) = EventId::parse(id_hex)
421        {
422            return Some(id);
423        }
424    }
425    None
426}
427
428/// Look up the parent message id (`e`-tag with `"reply"` marker) on
429/// a kind 42 event.
430#[must_use]
431pub fn channel_reply_id(event: &Event) -> Option<EventId> {
432    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
433    for tag in event.tags.find_all(&head) {
434        if tag.get(3) == Some("reply")
435            && let Some(id_hex) = tag.get(1)
436            && let Ok(id) = EventId::parse(id_hex)
437        {
438            return Some(id);
439        }
440    }
441    None
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::Keys;
448
449    fn keys() -> Keys {
450        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
451    }
452
453    fn event_id_zero() -> EventId {
454        EventId::from_byte_array([0x42; 32])
455    }
456
457    fn event_id_one() -> EventId {
458        EventId::from_byte_array([0xab; 32])
459    }
460
461    #[test]
462    fn channel_metadata_round_trips_through_json() {
463        let metadata = ChannelMetadata::new()
464            .name("Demo")
465            .about("desc")
466            .picture("https://example.com/p.png")
467            .relays([
468                RelayUrl::parse("wss://nos.lol").unwrap(),
469                RelayUrl::parse("wss://relay.example/").unwrap(),
470            ]);
471        let json = metadata.to_json().unwrap();
472        let parsed = ChannelMetadata::from_json(&json).unwrap();
473        assert_eq!(parsed, metadata);
474    }
475
476    #[test]
477    fn channel_metadata_extra_fields_round_trip() {
478        let json = r#"{"name":"X","custom":[1,2,3]}"#;
479        let parsed = ChannelMetadata::from_json(json).unwrap();
480        assert_eq!(parsed.name.as_deref(), Some("X"));
481        assert_eq!(
482            parsed
483                .extra
484                .get("custom")
485                .and_then(|v| v.as_array())
486                .map(Vec::len),
487            Some(3),
488        );
489    }
490
491    #[test]
492    fn channel_metadata_rejects_non_string_picture() {
493        let json = r#"{"picture":123}"#;
494        let err = ChannelMetadata::from_json(json).unwrap_err();
495        assert!(matches!(
496            err,
497            ChannelMetadataError::InvalidStringField { key: "picture", .. }
498        ));
499    }
500
501    #[test]
502    fn channel_metadata_rejects_invalid_relay_url() {
503        let json = r#"{"relays":["not-a-relay"]}"#;
504        let err = ChannelMetadata::from_json(json).unwrap_err();
505        assert!(matches!(err, ChannelMetadataError::InvalidRelayUrl(_)));
506    }
507
508    #[test]
509    fn channel_create_emits_kind_40_with_metadata_in_content() {
510        let metadata = ChannelMetadata::new().name("hello");
511        let event = EventBuilder::channel_create(&metadata)
512            .unwrap()
513            .sign_with_keys(&keys())
514            .unwrap();
515        assert_eq!(event.kind, KIND_CHANNEL_CREATE);
516        let parsed = ChannelMetadata::from_json(&event.content).unwrap();
517        assert_eq!(parsed.name.as_deref(), Some("hello"));
518    }
519
520    #[test]
521    fn channel_metadata_update_includes_root_marker() {
522        let metadata = ChannelMetadata::new().name("upd");
523        let channel = event_id_zero();
524        let event = EventBuilder::channel_metadata_update(&metadata, channel, None)
525            .unwrap()
526            .sign_with_keys(&keys())
527            .unwrap();
528        assert_eq!(event.kind, KIND_CHANNEL_METADATA);
529        assert_eq!(channel_root_id(&event), Some(channel));
530    }
531
532    #[test]
533    fn channel_message_root_only_carries_one_e_tag() {
534        let channel = event_id_zero();
535        let event = EventBuilder::channel_message_root(channel, None, "hello")
536            .sign_with_keys(&keys())
537            .unwrap();
538        assert_eq!(event.kind, KIND_CHANNEL_MESSAGE);
539        assert_eq!(channel_root_id(&event), Some(channel));
540        assert_eq!(channel_reply_id(&event), None);
541    }
542
543    #[test]
544    fn channel_message_reply_carries_root_reply_and_p() {
545        let channel = event_id_zero();
546        let parent = event_id_one();
547        let parent_author = *keys().public_key();
548        let event = EventBuilder::channel_message_reply(
549            channel,
550            None,
551            parent,
552            None,
553            parent_author,
554            None,
555            "yo",
556        )
557        .sign_with_keys(&keys())
558        .unwrap();
559        assert_eq!(channel_root_id(&event), Some(channel));
560        assert_eq!(channel_reply_id(&event), Some(parent));
561    }
562
563    #[test]
564    fn hide_message_with_reason_emits_json_content() {
565        let reason = HideReason::new().reason("dick pic");
566        let event = EventBuilder::channel_hide_message(event_id_zero(), Some(&reason))
567            .sign_with_keys(&keys())
568            .unwrap();
569        assert_eq!(event.kind, KIND_CHANNEL_HIDE_MESSAGE);
570        assert!(event.content.contains("\"reason\":\"dick pic\""));
571    }
572
573    #[test]
574    fn hide_message_without_reason_has_empty_content() {
575        let event = EventBuilder::channel_hide_message(event_id_zero(), None)
576            .sign_with_keys(&keys())
577            .unwrap();
578        assert_eq!(event.content, "");
579    }
580
581    #[test]
582    fn mute_user_carries_p_tag_for_target() {
583        let target = *keys().public_key();
584        let event = EventBuilder::channel_mute_user(target, None)
585            .sign_with_keys(&keys())
586            .unwrap();
587        assert_eq!(event.kind, KIND_CHANNEL_MUTE_USER);
588        let p_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
589        let p_tag = event.tags.find_first(&p_kind).unwrap();
590        assert_eq!(p_tag.get(1), Some(target.to_hex().as_str()));
591    }
592
593    #[test]
594    fn hide_reason_round_trips_extra_fields() {
595        let json = r#"{"reason":"x","custom":42}"#;
596        let parsed = HideReason::from_json(json).unwrap();
597        assert_eq!(parsed.reason.as_deref(), Some("x"));
598        assert_eq!(
599            parsed
600                .extra
601                .get("custom")
602                .and_then(serde_json::Value::as_i64),
603            Some(42),
604        );
605    }
606}