Skip to main content

nula_core/nips/
nipa0.rs

1//! [NIP-A0] Voice Messages.
2//!
3//! Two regular kinds for short voice notes:
4//!
5//! - `kind: 1222` — root voice message. `.content` MUST be a URL
6//!   pointing at an audio file.
7//! - `kind: 1244` — voice reply. Follows NIP-22 comment scoping.
8//!
9//! Visual previews can be carried via NIP-92 `imeta` tags with the
10//! per-spec `waveform` and `duration` fields.
11//!
12//! [NIP-A0]: https://github.com/nostr-protocol/nips/blob/master/A0.md
13
14use thiserror::Error;
15
16use crate::event::{Event, EventBuilder, Kind, Tag};
17use crate::nips::nip92::{MediaAttachment, MediaAttachmentError};
18use crate::types::{Url, UrlError};
19
20/// `kind: 1222` — root voice message.
21pub const KIND_VOICE_MESSAGE: Kind = Kind::VOICE_MESSAGE;
22
23/// `kind: 1244` — voice reply.
24pub const KIND_VOICE_MESSAGE_REPLY: Kind = Kind::VOICE_MESSAGE_REPLY;
25
26/// Visual preview metadata for a voice attachment (NIP-92 `imeta`
27/// extension).
28#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub struct VoicePreview {
30    /// Whitespace-separated amplitude values (0–100).
31    pub waveform: Option<String>,
32    /// Audio length in seconds (stringified).
33    pub duration_seconds: Option<u64>,
34}
35
36/// Typed bundle for a `kind: 1222` / `kind: 1244` voice event.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct VoiceMessage {
39    /// Whether this is a reply (`true` ⇒ `kind: 1244`).
40    pub is_reply: bool,
41    /// URL pointing at the audio file (mirrors `.content`).
42    pub audio_url: Url,
43    /// Optional NIP-92 `imeta` tag describing the audio.
44    pub media: Option<MediaAttachment>,
45    /// Optional preview parsed from the `imeta` tag.
46    pub preview: VoicePreview,
47    /// Forward-compatible passthrough for unknown tags.
48    pub extra_tags: Vec<Tag>,
49}
50
51/// Errors raised while parsing a NIP-A0 event.
52#[derive(Debug, Error)]
53#[non_exhaustive]
54pub enum VoiceMessageError {
55    /// Event kind is not `1222` / `1244`.
56    #[error("unexpected kind for NIP-A0 voice message: {}", .0.as_u16())]
57    WrongKind(Kind),
58    /// `.content` is not a parseable URL.
59    #[error(transparent)]
60    InvalidAudioUrl(#[from] UrlError),
61    /// Wrapped imeta parser error.
62    #[error(transparent)]
63    InvalidMediaAttachment(#[from] MediaAttachmentError),
64    /// Voice preview `duration` could not be parsed as an integer.
65    #[error("invalid voice preview `duration` value `{0}`")]
66    InvalidDuration(String),
67}
68
69impl VoiceMessage {
70    /// Construct a root voice message.
71    #[must_use]
72    pub fn root(audio_url: Url) -> Self {
73        Self {
74            is_reply: false,
75            audio_url,
76            media: None,
77            preview: VoicePreview::default(),
78            extra_tags: Vec::new(),
79        }
80    }
81
82    /// Construct a reply voice message.
83    #[must_use]
84    pub fn reply(audio_url: Url) -> Self {
85        Self {
86            is_reply: true,
87            ..Self::root(audio_url)
88        }
89    }
90
91    /// Attach a NIP-92 media bundle. Side-extracts the
92    /// `waveform`/`duration` extras into [`Self::preview`].
93    #[must_use]
94    pub fn media(mut self, media: MediaAttachment) -> Self {
95        self.preview = preview_from_media(&media);
96        self.media = Some(media);
97        self
98    }
99
100    /// Parse a `kind: 1222` or `kind: 1244` voice-message event.
101    ///
102    /// # Errors
103    ///
104    /// See [`VoiceMessageError`] for the failure modes.
105    pub fn from_event(event: &Event) -> Result<Self, VoiceMessageError> {
106        let is_reply = match event.kind {
107            KIND_VOICE_MESSAGE => false,
108            KIND_VOICE_MESSAGE_REPLY => true,
109            other => return Err(VoiceMessageError::WrongKind(other)),
110        };
111        let audio_url = Url::parse(event.content.trim())?;
112        let mut media: Option<MediaAttachment> = None;
113        let mut extra_tags: Vec<Tag> = Vec::new();
114        for tag in &event.tags {
115            if tag.name() == "imeta" && media.is_none() {
116                media = Some(MediaAttachment::from_tag(tag)?);
117            } else {
118                extra_tags.push(tag.clone());
119            }
120        }
121        let preview = media.as_ref().map(preview_from_media).unwrap_or_default();
122        Ok(Self {
123            is_reply,
124            audio_url,
125            media,
126            preview,
127            extra_tags,
128        })
129    }
130}
131
132fn preview_from_media(media: &MediaAttachment) -> VoicePreview {
133    let mut preview = VoicePreview::default();
134    for (key, value) in &media.extra_fields {
135        match key.as_str() {
136            "waveform" => preview.waveform = Some(value.clone()),
137            "duration" => {
138                if let Ok(secs) = value.parse::<u64>() {
139                    preview.duration_seconds = Some(secs);
140                }
141            }
142            _ => {}
143        }
144    }
145    preview
146}
147
148impl EventBuilder {
149    /// Author a NIP-A0 voice message.
150    ///
151    /// # Errors
152    ///
153    /// Propagates [`MediaAttachmentError`] when the optional
154    /// [`VoiceMessage::media`] bundle violates NIP-92 invariants.
155    pub fn voice_message(msg: &VoiceMessage) -> Result<Self, VoiceMessageError> {
156        let kind = if msg.is_reply {
157            KIND_VOICE_MESSAGE_REPLY
158        } else {
159            KIND_VOICE_MESSAGE
160        };
161        let mut builder = Self::new(kind, msg.audio_url.as_str());
162        if let Some(media) = &msg.media {
163            builder = builder.tag(media.to_tag()?);
164        }
165        for tag in &msg.extra_tags {
166            builder = builder.tag(tag.clone());
167        }
168        Ok(builder)
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::Keys;
176
177    fn keys() -> Keys {
178        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
179    }
180
181    fn sample_media() -> MediaAttachment {
182        MediaAttachment::new(Url::parse("https://example.com/voice.mp4").unwrap())
183            .extra("waveform", "0 5 100 50")
184            .extra("duration", "8")
185    }
186
187    #[test]
188    fn voice_message_root_round_trip() {
189        let url = Url::parse("https://example.com/voice.mp4").unwrap();
190        let msg = VoiceMessage::root(url).media(sample_media());
191        let event = EventBuilder::voice_message(&msg)
192            .unwrap()
193            .sign_with_keys(&keys())
194            .unwrap();
195        let parsed = VoiceMessage::from_event(&event).unwrap();
196        assert!(!parsed.is_reply);
197        assert_eq!(parsed.preview.waveform.as_deref(), Some("0 5 100 50"));
198        assert_eq!(parsed.preview.duration_seconds, Some(8));
199    }
200
201    #[test]
202    fn voice_message_reply_round_trip() {
203        let url = Url::parse("https://example.com/reply.mp4").unwrap();
204        let msg = VoiceMessage::reply(url);
205        let event = EventBuilder::voice_message(&msg)
206            .unwrap()
207            .sign_with_keys(&keys())
208            .unwrap();
209        let parsed = VoiceMessage::from_event(&event).unwrap();
210        assert!(parsed.is_reply);
211        assert!(parsed.media.is_none());
212    }
213
214    #[test]
215    fn wrong_kind_is_rejected() {
216        let event = EventBuilder::text_note("nope")
217            .sign_with_keys(&keys())
218            .unwrap();
219        assert!(matches!(
220            VoiceMessage::from_event(&event),
221            Err(VoiceMessageError::WrongKind(_))
222        ));
223    }
224
225    #[test]
226    fn invalid_audio_url_is_rejected() {
227        // `.content` MUST be a parseable URL per spec; bare text fails.
228        let event = EventBuilder::new(KIND_VOICE_MESSAGE, "not a url")
229            .sign_with_keys(&keys())
230            .unwrap();
231        let err = VoiceMessage::from_event(&event).expect_err("must reject");
232        assert!(matches!(err, VoiceMessageError::InvalidAudioUrl(_)));
233    }
234
235    #[test]
236    fn preview_extracts_waveform_and_duration_in_isolation() {
237        // Direct call to the side extractor proves the `imeta` extra
238        // fields are picked up even when [`VoiceMessage::media`] is
239        // attached after construction.
240        let media = sample_media();
241        let preview = preview_from_media(&media);
242        assert_eq!(preview.waveform.as_deref(), Some("0 5 100 50"));
243        assert_eq!(preview.duration_seconds, Some(8));
244
245        // Non-numeric `duration` is silently dropped (lenient parse).
246        let bad = MediaAttachment::new(Url::parse("https://example.com/v.mp4").unwrap())
247            .extra("duration", "not-a-number");
248        let lenient = preview_from_media(&bad);
249        assert!(lenient.duration_seconds.is_none());
250    }
251}