A voice message arrives as a voice message
Discord marks one with a flag on the message and puts the length and a waveform on the attachment. None of the three were read, so what reached the client was an audio file called voice-message.ogg and nothing else: no indication anybody had spoken it, no length, and no picture of the sound. That is ordinary traffic in a DM - there are fourteen of them in this account's own scrollback. So the flag is read from the message and carried down to each attachment it brought, and `duration_secs` and `waveform` come with it. The waveform stays base64 rather than being expanded into numbers: it is drawn once, and this is four times smaller on the wire and in the store. The flag decides, not the shape of the file. An .ogg somebody attached on purpose is a file they sent, and drawing it as a voice message would claim they spoke it - so that case is tested alongside. The wire-name test earned itself again: `duration_secs` went out snake_case because this struct renames field by field rather than wholesale, which is exactly the silent frontend mismatch that test exists to catch. Matrix has voice messages too and is deliberately not included - MSC3245 shapes them nothing like this, milliseconds and an array of 0-1024 samples against seconds and base64 bytes, and that is its own piece of work. Refs #186. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -204,6 +204,13 @@ pub(super) fn extract_body(d: &Value) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Discord message flag 1 << 13: this message is somebody speaking.
|
||||
///
|
||||
/// Named rather than written as 8192 at the point of use, because the number
|
||||
/// says nothing and this is the only thing that distinguishes a voice message
|
||||
/// from an ordinary audio attachment.
|
||||
const IS_VOICE_MESSAGE: u64 = 1 << 13;
|
||||
|
||||
/// Discord's own `attachments` array, kept structured instead of being
|
||||
/// flattened into body text. Discord already reports filename, size,
|
||||
/// dimensions and content type per file, so there is nothing to infer - and
|
||||
@@ -221,6 +228,74 @@ pub(super) fn is_empty_message(body: &str, embeds: &[Embed], attachments: &[Atta
|
||||
body.is_empty() && embeds.is_empty() && attachments.is_empty()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod voice_tests {
|
||||
use super::extract_attachments;
|
||||
use serde_json::json;
|
||||
|
||||
/// A voice message as Discord actually sends one, taken from the shape in
|
||||
/// this account's own scrollback: an ogg named voice-message.ogg, with the
|
||||
/// length and the waveform on the attachment and the flag on the message.
|
||||
fn spoken() -> serde_json::Value {
|
||||
json!({
|
||||
"flags": 8192,
|
||||
"attachments": [{
|
||||
"url": "https://cdn.discordapp.com/attachments/1/2/voice-message.ogg",
|
||||
"filename": "voice-message.ogg",
|
||||
"content_type": "audio/ogg",
|
||||
"size": 453003,
|
||||
"duration_secs": 12.4,
|
||||
"waveform": "AAAICBAQGBggIA=="
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_voice_message_says_that_it_is_one() {
|
||||
let att = &extract_attachments(&spoken())[0];
|
||||
assert!(att.voice, "the flag was not read");
|
||||
assert_eq!(att.duration_secs, Some(12.4));
|
||||
assert_eq!(att.waveform.as_deref(), Some("AAAICBAQGBggIA=="));
|
||||
// Still an audio attachment underneath, so everything that already
|
||||
// knew how to play one still can.
|
||||
assert_eq!(att.kind, "audio");
|
||||
}
|
||||
|
||||
/// The distinction that has to survive: an .ogg somebody attached on
|
||||
/// purpose is a file they sent, and drawing it as a voice message would
|
||||
/// claim they spoke it.
|
||||
#[test]
|
||||
fn an_ordinary_sound_file_is_not_a_voice_message() {
|
||||
let mut ordinary = spoken();
|
||||
ordinary["flags"] = json!(0);
|
||||
let att = &extract_attachments(&ordinary)[0];
|
||||
assert!(!att.voice);
|
||||
assert_eq!(att.kind, "audio");
|
||||
}
|
||||
|
||||
/// Discord sets the flag on the message, so every attachment on it is part
|
||||
/// of the same recording - but a message with no flag and no waveform must
|
||||
/// come through exactly as it always did.
|
||||
#[test]
|
||||
fn a_picture_is_untouched_by_any_of_this() {
|
||||
let picture = json!({
|
||||
"attachments": [{
|
||||
"url": "https://cdn.discordapp.com/attachments/1/2/cat.png",
|
||||
"filename": "cat.png",
|
||||
"content_type": "image/png",
|
||||
"size": 100,
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}]
|
||||
});
|
||||
let att = &extract_attachments(&picture)[0];
|
||||
assert_eq!(att.kind, "image");
|
||||
assert!(!att.voice);
|
||||
assert_eq!(att.duration_secs, None);
|
||||
assert_eq!(att.waveform, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod forwarded_tests {
|
||||
use super::{extract_attachments, extract_body, extract_reply, forwarded_attachments};
|
||||
@@ -574,6 +649,10 @@ pub(super) fn forwarded_attachments(d: &Value) -> Vec<Attachment> {
|
||||
|
||||
pub(super) fn extract_attachments(d: &Value) -> Vec<Attachment> {
|
||||
let Some(atts) = d["attachments"].as_array() else { return Vec::new() };
|
||||
// Whether somebody spoke this message rather than attaching a sound file.
|
||||
// The flag is on the message, the recording is on the attachment, so it is
|
||||
// read once here and carried down to each.
|
||||
let spoken = d["flags"].as_u64().unwrap_or(0) & IS_VOICE_MESSAGE != 0;
|
||||
atts.iter()
|
||||
.filter_map(|att| {
|
||||
let url = att["url"].as_str()?;
|
||||
@@ -592,6 +671,15 @@ pub(super) fn extract_attachments(d: &Value) -> Vec<Attachment> {
|
||||
height: att["height"].as_u64().map(|v| v as u32),
|
||||
url: Some(url.to_string()),
|
||||
mimetype,
|
||||
// Both only ever appear on a voice message, and both were being
|
||||
// dropped: what arrived was an audio file with no indication of
|
||||
// what it was, no length, and no picture of the sound.
|
||||
duration_secs: att["duration_secs"].as_f64(),
|
||||
waveform: att["waveform"].as_str().map(str::to_string),
|
||||
// The flag decides, not the shape of the attachment. An .ogg
|
||||
// somebody uploaded on purpose is a file they sent, and calling
|
||||
// it a voice message would draw it as something it is not.
|
||||
voice: spoken,
|
||||
..Default::default()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,6 +32,14 @@ pub(super) fn build_attachment(content: &Value, path: Option<String>, thumbnail_
|
||||
mimetype,
|
||||
path,
|
||||
thumbnail_path,
|
||||
// Matrix has voice messages too - MSC3245, with the length and the
|
||||
// waveform under an MSC1767 audio block - but they are shaped nothing
|
||||
// like Discord's (milliseconds and an array of 0-1024 samples, against
|
||||
// seconds and base64 bytes). Reading them is its own piece of work;
|
||||
// this is the Discord ticket.
|
||||
duration_secs: None,
|
||||
waveform: None,
|
||||
voice: false,
|
||||
url: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,6 +495,27 @@ pub struct Attachment {
|
||||
/// blurhash, where the sender provided one).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blurhash: Option<String>,
|
||||
/// How long the recording runs, where the service says.
|
||||
///
|
||||
/// Carried for voice messages, which are ordinary traffic in Discord DMs
|
||||
/// and arrived here as an audio file with nothing to say what it was.
|
||||
/// Knowing the length before a byte is fetched is most of what makes one
|
||||
/// readable: a voice message is a thing people decide whether to listen to.
|
||||
#[serde(rename = "durationSecs", skip_serializing_if = "Option::is_none")]
|
||||
pub duration_secs: Option<f64>,
|
||||
/// The picture of the sound, as the sender's client drew it.
|
||||
///
|
||||
/// Discord sends up to 256 bytes, base64'd, each one an amplitude sample.
|
||||
/// Kept encoded rather than expanded into numbers: it is drawn once and
|
||||
/// this is four times smaller on the wire and in the scrollback.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub waveform: Option<String>,
|
||||
/// Somebody spoke this rather than attaching a file of it.
|
||||
///
|
||||
/// The service's own flag, not a guess from the mimetype: an .ogg someone
|
||||
/// uploaded deliberately is an audio file and should be offered as one.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub voice: bool,
|
||||
/// Locally cached full-size media.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
@@ -736,10 +757,18 @@ mod tests {
|
||||
path: Some("file:///x".into()),
|
||||
thumbnail_path: Some("file:///t".into()),
|
||||
url: Some("https://x".into()),
|
||||
duration_secs: Some(12.4),
|
||||
waveform: Some("AAAI".into()),
|
||||
voice: true,
|
||||
};
|
||||
let v = serde_json::to_value(&a).unwrap();
|
||||
let keys: Vec<&str> = v.as_object().unwrap().keys().map(String::as_str).collect();
|
||||
assert!(keys.contains(&"thumbnailPath"), "got {keys:?}");
|
||||
// The voice-message pair, which is two words in Rust and one on the
|
||||
// wire - exactly the shape this test exists to catch.
|
||||
assert!(keys.contains(&"durationSecs"), "got {keys:?}");
|
||||
assert!(keys.contains(&"waveform"), "got {keys:?}");
|
||||
assert!(keys.contains(&"voice"), "got {keys:?}");
|
||||
assert!(!keys.iter().any(|k| k.contains('_')), "snake_case leaked: {keys:?}");
|
||||
}
|
||||
|
||||
@@ -749,6 +778,9 @@ mod tests {
|
||||
fn empty_attachment_fields_are_omitted() {
|
||||
let a = Attachment { kind: "file".into(), ..Default::default() };
|
||||
let v = serde_json::to_value(&a).unwrap();
|
||||
// `voice` included: false is the ordinary case for every attachment
|
||||
// there has ever been, and sending it on all of them would be a flag
|
||||
// on the wire that means nothing.
|
||||
assert_eq!(v.as_object().unwrap().keys().collect::<Vec<_>>(), vec!["kind"]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user