diff --git a/src/main/java/net/dv8tion/jda/api/JDA.java b/src/main/java/net/dv8tion/jda/api/JDA.java index e5fb70beee..463212491e 100644 --- a/src/main/java/net/dv8tion/jda/api/JDA.java +++ b/src/main/java/net/dv8tion/jda/api/JDA.java @@ -25,6 +25,7 @@ import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.entities.emoji.RichCustomEmoji; import net.dv8tion.jda.api.entities.sticker.*; +import net.dv8tion.jda.api.events.GenericEvent; import net.dv8tion.jda.api.hooks.IEventManager; import net.dv8tion.jda.api.interactions.commands.Command; import net.dv8tion.jda.api.interactions.commands.build.CommandData; @@ -39,6 +40,7 @@ import net.dv8tion.jda.api.requests.restaction.pagination.EntitlementPaginationAction; import net.dv8tion.jda.api.sharding.ShardManager; import net.dv8tion.jda.api.utils.MiscUtil; +import net.dv8tion.jda.api.utils.Once; import net.dv8tion.jda.api.utils.cache.CacheFlag; import net.dv8tion.jda.api.utils.cache.CacheView; import net.dv8tion.jda.api.utils.cache.SnowflakeCacheView; @@ -605,6 +607,47 @@ default boolean awaitShutdown() throws InterruptedException @Nonnull List getRegisteredListeners(); + /** + * Returns a reusable builder for a one-time event listener. + * + *

Note that this method only works if the {@link JDABuilder#setEventManager(IEventManager) event manager} + * is either the {@link net.dv8tion.jda.api.hooks.InterfacedEventManager InterfacedEventManager} + * or {@link net.dv8tion.jda.api.hooks.AnnotatedEventManager AnnotatedEventManager}. + *
Other implementations can support it as long as they call + * {@link net.dv8tion.jda.api.hooks.EventListener#onEvent(GenericEvent) EventListener.onEvent(GenericEvent)}. + * + *

Example: + * + *

Listening to a message from a channel and a user, after using a slash command: + *

{@code
+     * final Duration timeout = Duration.ofSeconds(5);
+     * event.reply("Reply in " + TimeFormat.RELATIVE.after(timeout) + " if you can!")
+     *         .setEphemeral(true)
+     *         .queue();
+     *
+     * event.getJDA().listenOnce(MessageReceivedEvent.class)
+     *         .filter(messageEvent -> messageEvent.getChannel().getIdLong() == event.getChannel().getIdLong())
+     *         .filter(messageEvent -> messageEvent.getAuthor().getIdLong() == event.getUser().getIdLong())
+     *         .timeout(timeout, () -> {
+     *             event.getHook().editOriginal("Timeout!").queue();
+     *         })
+     *         .subscribe(messageEvent -> {
+     *             event.getHook().editOriginal("You sent: " + messageEvent.getMessage().getContentRaw()).queue();
+     *         });
+     * }
+ * + * @param eventType + * Type of the event to listen to + * + * @throws IllegalArgumentException + * If the provided event type is {@code null} + * + * @return The one-time event listener builder + */ + @Nonnull + @CheckReturnValue + Once.Builder listenOnce(@Nonnull Class eventType); + /** * Retrieves the list of global commands. *
This list does not include guild commands! Use {@link Guild#retrieveCommands()} for guild commands. diff --git a/src/main/java/net/dv8tion/jda/api/utils/Once.java b/src/main/java/net/dv8tion/jda/api/utils/Once.java new file mode 100644 index 0000000000..3081a213b6 --- /dev/null +++ b/src/main/java/net/dv8tion/jda/api/utils/Once.java @@ -0,0 +1,287 @@ +/* + * Copyright 2015 Austin Keener, Michael Ritter, Florian Spieß, and the JDA contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.dv8tion.jda.api.utils; + +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.events.GenericEvent; +import net.dv8tion.jda.api.hooks.EventListener; +import net.dv8tion.jda.api.hooks.SubscribeEvent; +import net.dv8tion.jda.api.utils.concurrent.Task; +import net.dv8tion.jda.internal.utils.Checks; +import net.dv8tion.jda.internal.utils.JDALogger; +import net.dv8tion.jda.internal.utils.concurrent.task.GatewayTask; +import org.slf4j.Logger; + +import javax.annotation.CheckReturnValue; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.*; +import java.util.function.Consumer; +import java.util.function.Predicate; + +/** + * Helper class to listen to an event, once. + * + * @param Type of the event listened to + * + * @see JDA#listenOnce(Class) + */ +public class Once implements EventListener +{ + private static final Logger LOG = JDALogger.getLog(Once.class); + + private final JDA jda; + private final Class eventType; + private final List> filters; + private final CompletableFuture future; + private final GatewayTask task; + private final ScheduledFuture timeoutFuture; + private final Runnable timeoutCallback; + + protected Once(JDA jda, Class eventType, List> filters, Runnable timeoutCallback, Duration timeout, ScheduledExecutorService timeoutPool) + { + this.jda = jda; + this.eventType = eventType; + this.filters = new ArrayList<>(filters); + this.timeoutCallback = timeoutCallback; + + this.future = new CompletableFuture<>(); + this.task = createTask(); + this.timeoutFuture = scheduleTimeout(timeout, timeoutPool); + } + + @Nonnull + private GatewayTask createTask() + { + final GatewayTask task = new GatewayTask<>(future, () -> + { + // On cancellation, throw cancellation exception and cancel timeout + jda.removeEventListener(this); + future.completeExceptionally(new CancellationException()); + if (timeoutFuture != null) + timeoutFuture.cancel(false); + }); + task.onSetTimeout(e -> + { + throw new UnsupportedOperationException("You must set the timeout on Once.Builder#timeout"); + }); + return task; + } + + @Nullable + private ScheduledFuture scheduleTimeout(@Nullable Duration timeout, @Nullable ScheduledExecutorService timeoutPool) + { + if (timeout == null) return null; + if (timeoutPool == null) timeoutPool = jda.getGatewayPool(); + + return timeoutPool.schedule(() -> + { + // On timeout, throw timeout exception and run timeout callback + jda.removeEventListener(this); + if (!future.completeExceptionally(new TimeoutException())) + return; + if (timeoutCallback != null) + { + try + { + timeoutCallback.run(); + } + catch (Throwable e) + { + LOG.error("An error occurred while running the timeout callback", e); + if (e instanceof Error) + throw (Error) e; + } + } + }, timeout.toMillis(), TimeUnit.MILLISECONDS); + } + + @Override + @SubscribeEvent + public void onEvent(@Nonnull GenericEvent event) + { + if (!eventType.isInstance(event)) + return; + final E casted = eventType.cast(event); + try + { + if (filters.stream().allMatch(p -> p.test(casted))) + { + if (timeoutFuture != null) + timeoutFuture.cancel(false); + event.getJDA().removeEventListener(this); + future.complete(casted); + } + } + catch (Throwable e) + { + if (future.completeExceptionally(e)) + event.getJDA().removeEventListener(this); + if (e instanceof Error) + throw (Error) e; + } + } + + /** + * Builds a one-time event listener, can be reused. + * + * @param Type of the event listened to + */ + public static class Builder + { + private final JDA jda; + private final Class eventType; + private final List> filters = new ArrayList<>(); + + private ScheduledExecutorService timeoutPool; + private Duration timeout; + private Runnable timeoutCallback; + + /** + * Creates a builder for a one-time event listener + * + * @param jda + * The JDA instance + * @param eventType + * The event type to listen for + * + * @throws IllegalArgumentException + * If any of the parameters is null + */ + public Builder(@Nonnull JDA jda, @Nonnull Class eventType) + { + Checks.notNull(jda, "JDA"); + Checks.notNull(eventType, "Event type"); + this.jda = jda; + this.eventType = eventType; + } + + /** + * Adds an event filter, all filters need to return {@code true} for the event to be consumed. + * + *

If the filter throws an exception, this listener will unregister itself. + * + * @param filter + * The filter to add, returns {@code true} if the event can be consumed + * + * @throws IllegalArgumentException + * If the filter is null + * + * @return This instance for chaining convenience + */ + @Nonnull + public Builder filter(@Nonnull Predicate filter) + { + Checks.notNull(filter, "Filter"); + filters.add(filter); + return this; + } + + /** + * Sets the timeout duration, after which the event is no longer listener for. + * + * @param timeout + * The duration after which the event is no longer listener for + * + * @throws IllegalArgumentException + * If the timeout is null + * + * @return This instance for chaining convenience + */ + @Nonnull + public Builder timeout(@Nonnull Duration timeout) + { + return timeout(timeout, null); + } + + /** + * Sets the timeout duration, after which the event is no longer listener for, + * and the callback is run. + * + * @param timeout + * The duration after which the event is no longer listener for + * @param timeoutCallback + * The callback run after the duration + * + * @throws IllegalArgumentException + * If the timeout is null + * + * @return This instance for chaining convenience + */ + @Nonnull + public Builder timeout(@Nonnull Duration timeout, @Nullable Runnable timeoutCallback) + { + Checks.notNull(timeout, "Timeout"); + this.timeout = timeout; + this.timeoutCallback = timeoutCallback; + return this; + } + + /** + * Sets the thread pool used to schedule timeouts and run its callback. + * + *

By default {@link JDA#getGatewayPool()} is used. + * + * @param timeoutPool + * The thread pool to use for timeouts + * + * @throws IllegalArgumentException + * If the timeout pool is null + * + * @return This instance for chaining convenience + */ + @Nonnull + public Builder setTimeoutPool(@Nonnull ScheduledExecutorService timeoutPool) + { + Checks.notNull(timeoutPool, "Timeout pool"); + this.timeoutPool = timeoutPool; + return this; + } + + /** + * Starts listening for the event, once. + * + *

The task will be completed after all {@link #filter(Predicate) filters} return {@code true}. + * + *

Exceptions thrown in {@link Task#get() blocking} and {@link Task#onSuccess(Consumer) async} contexts includes: + *

    + *
  • {@link CancellationException} - When {@link Task#cancel()} is called
  • + *
  • {@link TimeoutException} - When the listener has expired
  • + *
  • Any exception thrown by the {@link #timeout(Duration, Runnable) timeout callback}
  • + *
+ * + * @throws IllegalArgumentException + * If the callback is null + * + * @return {@link Task} returning an event satisfying all preconditions + * + * @see Task#onSuccess(Consumer) + * @see Task#get() + */ + @Nonnull + @CheckReturnValue + public Task subscribe(@Nonnull Consumer callback) + { + final Once once = new Once<>(jda, eventType, filters, timeoutCallback, timeout, timeoutPool); + jda.addEventListener(once); + return once.task.onSuccess(callback); + } + } +} diff --git a/src/main/java/net/dv8tion/jda/internal/JDAImpl.java b/src/main/java/net/dv8tion/jda/internal/JDAImpl.java index f269cfc0ce..8ef202f4d3 100644 --- a/src/main/java/net/dv8tion/jda/internal/JDAImpl.java +++ b/src/main/java/net/dv8tion/jda/internal/JDAImpl.java @@ -1009,6 +1009,13 @@ public List getRegisteredListeners() return eventManager.getRegisteredListeners(); } + @Nonnull + @Override + public Once.Builder listenOnce(@Nonnull Class eventType) + { + return new Once.Builder<>(this, eventType); + } + @Nonnull @Override public RestAction> retrieveCommands(boolean withLocalizations)