diff --git a/src/main/java/net/dv8tion/jda/api/entities/Message.java b/src/main/java/net/dv8tion/jda/api/entities/Message.java
index 9273cf69fd..7b8f73f804 100644
--- a/src/main/java/net/dv8tion/jda/api/entities/Message.java
+++ b/src/main/java/net/dv8tion/jda/api/entities/Message.java
@@ -2253,6 +2253,13 @@ default MessageCreateAction replyFiles(@Nonnull Collection extends FileUpload>
*/
boolean isSuppressedNotifications();
+ /**
+ * Whether this message is a voice message.
+ *
+ * @return True, if this is a voice message
+ */
+ boolean isVoiceMessage();
+
/**
* Returns a possibly {@code null} {@link ThreadChannel ThreadChannel} that was started from this message.
* This can be {@code null} due to no ThreadChannel being started from it or the ThreadChannel later being deleted.
diff --git a/src/main/java/net/dv8tion/jda/api/utils/FileUpload.java b/src/main/java/net/dv8tion/jda/api/utils/FileUpload.java
index 6126253b85..ecd713b27a 100644
--- a/src/main/java/net/dv8tion/jda/api/utils/FileUpload.java
+++ b/src/main/java/net/dv8tion/jda/api/utils/FileUpload.java
@@ -32,9 +32,12 @@
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.*;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.OpenOption;
import java.nio.file.Path;
+import java.time.Duration;
+import java.util.Base64;
import java.util.function.Supplier;
/**
@@ -51,6 +54,9 @@ public class FileUpload implements Closeable, AttachedFile
private String name;
private TypedBody> body;
private String description;
+ private MediaType mediaType = Requester.MEDIA_TYPE_OCTET;
+ private byte[] waveform;
+ private double durationSeconds;
protected FileUpload(InputStream resource, String name)
{
@@ -358,6 +364,70 @@ public FileUpload setDescription(@Nullable String description)
return this;
}
+ /**
+ * Turns this attachment into a voice message with the provided waveform.
+ *
+ * @param mediaType
+ * The audio type for the attached audio file. Should be {@code audio/ogg} or similar.
+ * @param waveform
+ * The waveform of the audio, which is a low frequency sampling up to 256 bytes.
+ * @param duration
+ * The actual duration of the audio data.
+ *
+ * @throws IllegalArgumentException
+ * If null is provided or the waveform is not between 1 and 256 bytes long.
+ *
+ * @return The same FileUpload instance configured as a voice message attachment
+ */
+ @Nonnull
+ public FileUpload asVoiceMessage(@Nonnull MediaType mediaType, @Nonnull byte[] waveform, @Nonnull Duration duration)
+ {
+ Checks.notNull(duration, "Duration");
+ return this.asVoiceMessage(mediaType, waveform, duration.toNanos() / 1_000_000_000.0);
+ }
+
+ /**
+ * Turns this attachment into a voice message with the provided waveform.
+ *
+ * @param mediaType
+ * The audio type for the attached audio file. Should be {@code audio/ogg} or similar.
+ * @param waveform
+ * The waveform of the audio, which is a low frequency sampling up to 256 bytes.
+ * @param durationSeconds
+ * The actual duration of the audio data in seconds.
+ *
+ * @throws IllegalArgumentException
+ * If null is provided or the waveform is not between 1 and 256 bytes long.
+ *
+ * @return The same FileUpload instance configured as a voice message attachment
+ */
+ @Nonnull
+ public FileUpload asVoiceMessage(@Nonnull MediaType mediaType, @Nonnull byte[] waveform, double durationSeconds)
+ {
+ Checks.notNull(mediaType, "Media type");
+ Checks.notNull(waveform, "Waveform");
+ Checks.check(waveform.length > 0 && waveform.length <= 256, "Waveform must be between 1 and 256 bytes long");
+ Checks.check(Double.isFinite(durationSeconds), "Duration must be a finite number");
+ Checks.check(durationSeconds > 0, "Duration must be positive");
+ this.waveform = waveform;
+ this.durationSeconds = durationSeconds;
+ this.mediaType = mediaType;
+ return this;
+ }
+
+ /**
+ * Whether this attachment is a valid voice message attachment.
+ *
+ * @return True, if this is a voice message attachment.
+ */
+ public boolean isVoiceMessage()
+ {
+ return this.mediaType.type().equals("audio")
+ && this.durationSeconds > 0.0
+ && this.waveform != null
+ && this.waveform.length > 0;
+ }
+
/**
* The filename for the file.
*
@@ -425,17 +495,24 @@ public synchronized RequestBody getRequestBody(@Nonnull MediaType type)
@SuppressWarnings("ConstantConditions")
public synchronized void addPart(@Nonnull MultipartBody.Builder builder, int index)
{
- builder.addFormDataPart("files[" + index + "]", name, getRequestBody(Requester.MEDIA_TYPE_OCTET));
+ builder.addFormDataPart("files[" + index + "]", name, getRequestBody(mediaType));
}
@Nonnull
@Override
public DataObject toAttachmentData(int index)
{
- return DataObject.empty()
+ DataObject attachment = DataObject.empty()
.put("id", index)
.put("description", description == null ? "" : description)
+ .put("content_type", mediaType.toString())
.put("filename", name);
+ if (waveform != null && durationSeconds > 0)
+ {
+ attachment.put("waveform", new String(Base64.getEncoder().encode(waveform), StandardCharsets.UTF_8));
+ attachment.put("duration_secs", durationSeconds);
+ }
+ return attachment;
}
@Override
diff --git a/src/main/java/net/dv8tion/jda/api/utils/messages/MessageCreateBuilder.java b/src/main/java/net/dv8tion/jda/api/utils/messages/MessageCreateBuilder.java
index afa82c5858..96e987a862 100644
--- a/src/main/java/net/dv8tion/jda/api/utils/messages/MessageCreateBuilder.java
+++ b/src/main/java/net/dv8tion/jda/api/utils/messages/MessageCreateBuilder.java
@@ -24,6 +24,7 @@
import net.dv8tion.jda.internal.utils.Checks;
import net.dv8tion.jda.internal.utils.Helpers;
import net.dv8tion.jda.internal.utils.IOUtil;
+import org.jetbrains.annotations.NotNull;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@@ -181,7 +182,10 @@ public MessageCreateBuilder setFiles(@Nullable Collection extends FileUpload>
Checks.noneNull(files, "Files");
this.files.clear();
if (files != null)
+ {
this.files.addAll(files);
+ this.setVoiceMessageIfApplicable(files);
+ }
return this;
}
@@ -213,6 +217,7 @@ public MessageCreateBuilder addFiles(@Nonnull Collection extends FileUpload> f
{
Checks.noneNull(files, "Files");
this.files.addAll(files);
+ this.setVoiceMessageIfApplicable(files);
return this;
}
@@ -228,13 +233,24 @@ public MessageCreateBuilder setTTS(boolean tts)
@Override
public MessageCreateBuilder setSuppressedNotifications(boolean suppressed)
{
- if(suppressed)
+ if (suppressed)
messageFlags |= Message.MessageFlag.NOTIFICATIONS_SUPPRESSED.getValue();
else
messageFlags &= ~Message.MessageFlag.NOTIFICATIONS_SUPPRESSED.getValue();
return this;
}
+ @Nonnull
+ @Override
+ public MessageCreateBuilder setVoiceMessage(boolean voiceMessage)
+ {
+ if (voiceMessage)
+ messageFlags |= Message.MessageFlag.IS_VOICE_MESSAGE.getValue();
+ else
+ messageFlags &= ~Message.MessageFlag.IS_VOICE_MESSAGE.getValue();
+ return this;
+ }
+
@Override
public boolean isEmpty()
{
@@ -291,4 +307,10 @@ public MessageCreateBuilder closeFiles()
files.clear();
return this;
}
+
+ private void setVoiceMessageIfApplicable(@NotNull Collection extends FileUpload> files)
+ {
+ if (files.stream().anyMatch(FileUpload::isVoiceMessage))
+ this.setVoiceMessage(true);
+ }
}
diff --git a/src/main/java/net/dv8tion/jda/api/utils/messages/MessageCreateData.java b/src/main/java/net/dv8tion/jda/api/utils/messages/MessageCreateData.java
index 43110c8c27..b0a600431f 100644
--- a/src/main/java/net/dv8tion/jda/api/utils/messages/MessageCreateData.java
+++ b/src/main/java/net/dv8tion/jda/api/utils/messages/MessageCreateData.java
@@ -270,13 +270,23 @@ public boolean isTTS()
/**
* Whether this message is silent.
*
- * @return True, if the message will not trigger push and desktop notifications
+ * @return True, if the message will not trigger push and desktop notifications.
*/
public boolean isSuppressedNotifications()
{
return (flags & Message.MessageFlag.NOTIFICATIONS_SUPPRESSED.getValue()) != 0;
}
+ /**
+ * Whether this message is intended as a voice message.
+ *
+ * @return True, if this message is intended as a voice message.
+ */
+ public boolean isVoiceMessage()
+ {
+ return (flags & Message.MessageFlag.IS_VOICE_MESSAGE.getValue()) != 0;
+ }
+
/**
* The IDs for users which are allowed to be mentioned, or an empty list.
*
diff --git a/src/main/java/net/dv8tion/jda/api/utils/messages/MessageCreateRequest.java b/src/main/java/net/dv8tion/jda/api/utils/messages/MessageCreateRequest.java
index e9490b8764..60d460ed87 100644
--- a/src/main/java/net/dv8tion/jda/api/utils/messages/MessageCreateRequest.java
+++ b/src/main/java/net/dv8tion/jda/api/utils/messages/MessageCreateRequest.java
@@ -25,6 +25,7 @@
import net.dv8tion.jda.api.requests.RestAction;
import net.dv8tion.jda.api.utils.FileUpload;
import net.dv8tion.jda.internal.utils.Checks;
+import okhttp3.MediaType;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@@ -339,11 +340,24 @@ default R addFiles(@Nonnull FileUpload... files)
* @param suppressed
* True, if this message should not trigger push/desktop notifications
*
- * @return The same reply action, for chaining convenience
+ * @return The same instance for chaining
*/
@Nonnull
R setSuppressedNotifications(boolean suppressed);
+ /**
+ * Whether this message should be considered a voice message.
+ *
Voice messages must upload a valid voice message attachment, using {@link FileUpload#asVoiceMessage(MediaType, byte[], double)}.
+ *
+ * @param voiceMessage
+ * True, if this message is a voice message.
+ * Turned on automatically if attachment is a valid voice message attachment.
+ *
+ * @return The same instance for chaining
+ */
+ @Nonnull
+ R setVoiceMessage(boolean voiceMessage);
+
/**
* Applies the provided {@link MessageCreateData} to this request.
*
@@ -372,6 +386,7 @@ default R applyData(@Nonnull MessageCreateData data)
.setTTS(data.isTTS())
.setSuppressEmbeds(data.isSuppressEmbeds())
.setSuppressedNotifications(data.isSuppressedNotifications())
+ .setVoiceMessage(data.isVoiceMessage())
.setComponents(layoutComponents)
.setPoll(data.getPoll())
.setFiles(data.getFiles());
@@ -390,6 +405,7 @@ default R applyMessage(@Nonnull Message message)
.setEmbeds(embeds)
.setTTS(message.isTTS())
.setSuppressedNotifications(message.isSuppressedNotifications())
+ .setVoiceMessage(message.isVoiceMessage())
.setComponents(message.getActionRows())
.setPoll(message.getPoll() != null ? MessagePollData.from(message.getPoll()) : null);
}
diff --git a/src/main/java/net/dv8tion/jda/internal/entities/ReceivedMessage.java b/src/main/java/net/dv8tion/jda/internal/entities/ReceivedMessage.java
index da9816ec95..5e2c467da5 100644
--- a/src/main/java/net/dv8tion/jda/internal/entities/ReceivedMessage.java
+++ b/src/main/java/net/dv8tion/jda/internal/entities/ReceivedMessage.java
@@ -933,6 +933,12 @@ public boolean isSuppressedNotifications()
return (this.flags & MessageFlag.NOTIFICATIONS_SUPPRESSED.getValue()) != 0;
}
+ @Override
+ public boolean isVoiceMessage()
+ {
+ return (this.flags & MessageFlag.IS_VOICE_MESSAGE.getValue()) != 0;
+ }
+
@Nullable
@Override
public ThreadChannel getStartedThread()
diff --git a/src/main/java/net/dv8tion/jda/internal/utils/message/MessageCreateBuilderMixin.java b/src/main/java/net/dv8tion/jda/internal/utils/message/MessageCreateBuilderMixin.java
index 7cf498d989..45bbe1f2ce 100644
--- a/src/main/java/net/dv8tion/jda/internal/utils/message/MessageCreateBuilderMixin.java
+++ b/src/main/java/net/dv8tion/jda/internal/utils/message/MessageCreateBuilderMixin.java
@@ -108,4 +108,12 @@ default R setSuppressedNotifications(boolean suppressed)
getBuilder().setSuppressedNotifications(suppressed);
return (R) this;
}
+
+ @Nonnull
+ @Override
+ default R setVoiceMessage(boolean voiceMessage)
+ {
+ getBuilder().setVoiceMessage(voiceMessage);
+ return (R) this;
+ }
}
diff --git a/src/test/java/net/dv8tion/jda/test/assertions/restaction/RestActionAssertions.java b/src/test/java/net/dv8tion/jda/test/assertions/restaction/RestActionAssertions.java
index 3e12458291..49855098fa 100644
--- a/src/test/java/net/dv8tion/jda/test/assertions/restaction/RestActionAssertions.java
+++ b/src/test/java/net/dv8tion/jda/test/assertions/restaction/RestActionAssertions.java
@@ -22,6 +22,8 @@
import net.dv8tion.jda.api.utils.data.DataObject;
import net.dv8tion.jda.internal.requests.Requester;
import net.dv8tion.jda.internal.utils.EncodingUtil;
+import okhttp3.MediaType;
+import okhttp3.RequestBody;
import org.jetbrains.annotations.Contract;
import org.mockito.ThrowingConsumer;
@@ -74,6 +76,21 @@ public RestActionAssertions checkAssertions(@Nonnull ThrowingConsumer
return this;
}
+ @CheckReturnValue
+ @Contract("->this")
+ public RestActionAssertions hasMultipartBody()
+ {
+ return checkAssertions(request -> {
+ RequestBody body = request.getBody();
+ assertThat(body).isNotNull();
+ MediaType mediaType = body.contentType();
+ assertThat(mediaType).isNotNull();
+
+ assertThat(mediaType.toString())
+ .startsWith("multipart/form-data; boundary=");
+ });
+ }
+
@CheckReturnValue
@Contract("_->this")
public RestActionAssertions hasBodyEqualTo(@Nonnull DataObject expected)
diff --git a/src/test/java/net/dv8tion/jda/test/restaction/MessageCreateActionTest.java b/src/test/java/net/dv8tion/jda/test/restaction/MessageCreateActionTest.java
index e9a620aa90..591cbb4c37 100644
--- a/src/test/java/net/dv8tion/jda/test/restaction/MessageCreateActionTest.java
+++ b/src/test/java/net/dv8tion/jda/test/restaction/MessageCreateActionTest.java
@@ -33,22 +33,30 @@
import net.dv8tion.jda.api.utils.messages.MessagePollData;
import net.dv8tion.jda.internal.requests.restaction.MessageCreateActionImpl;
import net.dv8tion.jda.test.IntegrationTest;
+import okhttp3.MediaType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import javax.annotation.Nonnull;
+import java.time.Duration;
+import java.util.Base64;
import java.util.EnumSet;
import java.util.concurrent.TimeUnit;
import static net.dv8tion.jda.api.requests.Method.POST;
import static net.dv8tion.jda.test.restaction.MessageCreateActionTest.Data.emoji;
import static net.dv8tion.jda.test.restaction.MessageCreateActionTest.Data.pollAnswer;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.Mockito.when;
public class MessageCreateActionTest extends IntegrationTest
{
+ private static final byte[] voiceMessageAudio = {1, 2, 3};
+ private static final String voiceMessageMediaType = "audio/ogg";
+ private static final String voiceMessageFilename = "voice-message.ogg";
+
private static final String FIXED_CHANNEL_ID = "1234567890";
private static final String FIXED_NONCE = "123456";
private static final String ENDPOINT_URL = "channels/" + FIXED_CHANNEL_ID + "/messages";
@@ -142,6 +150,49 @@ void testPollOnly()
.whenQueueCalled();
}
+ @Test
+ void testSendVoiceMessage()
+ {
+ MessageCreateActionImpl action = new MessageCreateActionImpl(channel);
+
+ FileUpload file = Data.getVoiceMessageFileUpload(voiceMessageAudio, voiceMessageFilename, voiceMessageMediaType);
+
+ assertThat(file.isVoiceMessage()).isTrue();
+
+ action.addFiles(file);
+
+ assertThatRequestFrom(action)
+ .hasMultipartBody()
+ .hasBodyEqualTo(
+ defaultMessageRequest()
+ .put("flags", 1 << 13)
+ .put("attachments", DataArray.empty()
+ .add(Data.getVoiceMessageAttachmentBody(voiceMessageMediaType, voiceMessageFilename, voiceMessageAudio)))
+ ).whenQueueCalled();
+ }
+
+ @Test
+ void testSuppressVoiceMessage()
+ {
+ MessageCreateActionImpl action = new MessageCreateActionImpl(channel);
+
+ FileUpload file = Data.getVoiceMessageFileUpload(voiceMessageAudio, voiceMessageFilename, voiceMessageMediaType);
+
+ assertThat(file.isVoiceMessage()).isTrue();
+
+ action.addFiles(file);
+ action.setVoiceMessage(false);
+
+ assertThatRequestFrom(action)
+ .hasMultipartBody()
+ .hasBodyEqualTo(
+ defaultMessageRequest()
+ .put("flags", 0)
+ .put("attachments", DataArray.empty()
+ .add(Data.getVoiceMessageAttachmentBody(voiceMessageMediaType, voiceMessageFilename, voiceMessageAudio)))
+ ).whenQueueCalled();
+ }
+
@Test
void testFullFromBuilder()
{
@@ -169,6 +220,23 @@ protected DataObject normalizeRequestBody(@Nonnull DataObject body)
static class Data
{
+ static FileUpload getVoiceMessageFileUpload(byte[] fakeAudio, String fileName, String audioMediaType)
+ {
+ return FileUpload.fromData(fakeAudio, fileName)
+ .asVoiceMessage(MediaType.parse(audioMediaType), fakeAudio, Duration.ofSeconds(3));
+ }
+
+ static DataObject getVoiceMessageAttachmentBody(String audioMediaType, String fileName, byte[] fakeAudio)
+ {
+ return DataObject.empty()
+ .put("description", "")
+ .put("content_type", audioMediaType)
+ .put("duration_secs", 3.0)
+ .put("filename", fileName)
+ .put("id", 0)
+ .put("waveform", new String(Base64.getEncoder().encode(fakeAudio)));
+ }
+
static DataObject pollAnswer(long id, String title, DataObject emoji)
{
return DataObject.empty()