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:
2026-09-12 22:01:01 -04:00
co-authored by Claude Opus 5
parent 207de58cf7
commit 54ac74e4fd
3 changed files with 128 additions and 0 deletions
+88
View File
@@ -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()
})
})
+8
View File
@@ -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,
}
}
+32
View File
@@ -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"]);
}