1use thiserror::Error;
15
16use crate::event::{Event, EventBuilder, Kind, Tag};
17use crate::nips::nip92::{MediaAttachment, MediaAttachmentError};
18use crate::types::{Url, UrlError};
19
20pub const KIND_VOICE_MESSAGE: Kind = Kind::VOICE_MESSAGE;
22
23pub const KIND_VOICE_MESSAGE_REPLY: Kind = Kind::VOICE_MESSAGE_REPLY;
25
26#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub struct VoicePreview {
30 pub waveform: Option<String>,
32 pub duration_seconds: Option<u64>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct VoiceMessage {
39 pub is_reply: bool,
41 pub audio_url: Url,
43 pub media: Option<MediaAttachment>,
45 pub preview: VoicePreview,
47 pub extra_tags: Vec<Tag>,
49}
50
51#[derive(Debug, Error)]
53#[non_exhaustive]
54pub enum VoiceMessageError {
55 #[error("unexpected kind for NIP-A0 voice message: {}", .0.as_u16())]
57 WrongKind(Kind),
58 #[error(transparent)]
60 InvalidAudioUrl(#[from] UrlError),
61 #[error(transparent)]
63 InvalidMediaAttachment(#[from] MediaAttachmentError),
64 #[error("invalid voice preview `duration` value `{0}`")]
66 InvalidDuration(String),
67}
68
69impl VoiceMessage {
70 #[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 #[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 #[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 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 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 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 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 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}