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 */ 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 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 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 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()