use thiserror::Error;
use crate::event::{Event, EventBuilder, Kind, Tag};
use crate::nips::nip92::{MediaAttachment, MediaAttachmentError};
use crate::types::{Url, UrlError};
pub const KIND_VOICE_MESSAGE: Kind = Kind::VOICE_MESSAGE;
pub const KIND_VOICE_MESSAGE_REPLY: Kind = Kind::VOICE_MESSAGE_REPLY;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct VoicePreview {
pub waveform: Option<String>,
pub duration_seconds: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VoiceMessage {
pub is_reply: bool,
pub audio_url: Url,
pub media: Option<MediaAttachment>,
pub preview: VoicePreview,
pub extra_tags: Vec<Tag>,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum VoiceMessageError {
#[error("unexpected kind for NIP-A0 voice message: {}", .0.as_u16())]
WrongKind(Kind),
#[error(transparent)]
InvalidAudioUrl(#[from] UrlError),
#[error(transparent)]
InvalidMediaAttachment(#[from] MediaAttachmentError),
#[error("invalid voice preview `duration` value `{0}`")]
InvalidDuration(String),
}
impl VoiceMessage {
#[must_use]
pub fn root(audio_url: Url) -> Self {
Self {
is_reply: false,
audio_url,
media: None,
preview: VoicePreview::default(),
extra_tags: Vec::new(),
}
}
#[must_use]
pub fn reply(audio_url: Url) -> Self {
Self {
is_reply: true,
..Self::root(audio_url)
}
}
#[must_use]
pub fn media(mut self, media: MediaAttachment) -> Self {
self.preview = preview_from_media(&media);
self.media = Some(media);
self
}
pub fn from_event(event: &Event) -> Result<Self, VoiceMessageError> {
let is_reply = match event.kind {
KIND_VOICE_MESSAGE => false,
KIND_VOICE_MESSAGE_REPLY => true,
other => return Err(VoiceMessageError::WrongKind(other)),
};
let audio_url = Url::parse(event.content.trim())?;
let mut media: Option<MediaAttachment> = None;
let mut extra_tags: Vec<Tag> = Vec::new();
for tag in &event.tags {
if tag.name() == "imeta" && media.is_none() {
media = Some(MediaAttachment::from_tag(tag)?);
} else {
extra_tags.push(tag.clone());
}
}
let preview = media.as_ref().map(preview_from_media).unwrap_or_default();
Ok(Self {
is_reply,
audio_url,
media,
preview,
extra_tags,
})
}
}
fn preview_from_media(media: &MediaAttachment) -> VoicePreview {
let mut preview = VoicePreview::default();
for (key, value) in &media.extra_fields {
match key.as_str() {
"waveform" => preview.waveform = Some(value.clone()),
"duration" => {
if let Ok(secs) = value.parse::<u64>() {
preview.duration_seconds = Some(secs);
}
}
_ => {}
}
}
preview
}
impl EventBuilder {
pub fn voice_message(msg: &VoiceMessage) -> Result<Self, VoiceMessageError> {
let kind = if msg.is_reply {
KIND_VOICE_MESSAGE_REPLY
} else {
KIND_VOICE_MESSAGE
};
let mut builder = Self::new(kind, msg.audio_url.as_str());
if let Some(media) = &msg.media {
builder = builder.tag(media.to_tag()?);
}
for tag in &msg.extra_tags {
builder = builder.tag(tag.clone());
}
Ok(builder)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keys;
fn keys() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
}
fn sample_media() -> MediaAttachment {
MediaAttachment::new(Url::parse("https://example.com/voice.mp4").unwrap())
.extra("waveform", "0 5 100 50")
.extra("duration", "8")
}
#[test]
fn voice_message_root_round_trip() {
let url = Url::parse("https://example.com/voice.mp4").unwrap();
let msg = VoiceMessage::root(url).media(sample_media());
let event = EventBuilder::voice_message(&msg)
.unwrap()
.sign_with_keys(&keys())
.unwrap();
let parsed = VoiceMessage::from_event(&event).unwrap();
assert!(!parsed.is_reply);
assert_eq!(parsed.preview.waveform.as_deref(), Some("0 5 100 50"));
assert_eq!(parsed.preview.duration_seconds, Some(8));
}
#[test]
fn voice_message_reply_round_trip() {
let url = Url::parse("https://example.com/reply.mp4").unwrap();
let msg = VoiceMessage::reply(url);
let event = EventBuilder::voice_message(&msg)
.unwrap()
.sign_with_keys(&keys())
.unwrap();
let parsed = VoiceMessage::from_event(&event).unwrap();
assert!(parsed.is_reply);
assert!(parsed.media.is_none());
}
#[test]
fn wrong_kind_is_rejected() {
let event = EventBuilder::text_note("nope")
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
VoiceMessage::from_event(&event),
Err(VoiceMessageError::WrongKind(_))
));
}
#[test]
fn invalid_audio_url_is_rejected() {
let event = EventBuilder::new(KIND_VOICE_MESSAGE, "not a url")
.sign_with_keys(&keys())
.unwrap();
let err = VoiceMessage::from_event(&event).expect_err("must reject");
assert!(matches!(err, VoiceMessageError::InvalidAudioUrl(_)));
}
#[test]
fn preview_extracts_waveform_and_duration_in_isolation() {
let media = sample_media();
let preview = preview_from_media(&media);
assert_eq!(preview.waveform.as_deref(), Some("0 5 100 50"));
assert_eq!(preview.duration_seconds, Some(8));
let bad = MediaAttachment::new(Url::parse("https://example.com/v.mp4").unwrap())
.extra("duration", "not-a-number");
let lenient = preview_from_media(&bad);
assert!(lenient.duration_seconds.is_none());
}
}