Skip to content

Commit

Permalink
Add JDA#listenOnce (#2683)
Browse files Browse the repository at this point in the history
  • Loading branch information
freya022 authored Aug 2, 2024
1 parent 158e2d3 commit 0f2e837
Show file tree
Hide file tree
Showing 3 changed files with 337 additions and 0 deletions.
43 changes: 43 additions & 0 deletions src/main/java/net/dv8tion/jda/api/JDA.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -605,6 +607,47 @@ default boolean awaitShutdown() throws InterruptedException
@Nonnull
List<Object> getRegisteredListeners();

/**
* Returns a reusable builder for a one-time event listener.
*
* <p>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}.
* <br>Other implementations can support it as long as they call
* {@link net.dv8tion.jda.api.hooks.EventListener#onEvent(GenericEvent) EventListener.onEvent(GenericEvent)}.
*
* <p><b>Example:</b>
*
* <p>Listening to a message from a channel and a user, after using a slash command:
* <pre>{@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();
* });
* }</pre>
*
* @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
<E extends GenericEvent> Once.Builder<E> listenOnce(@Nonnull Class<E> eventType);

/**
* Retrieves the list of global commands.
* <br>This list does not include guild commands! Use {@link Guild#retrieveCommands()} for guild commands.
Expand Down
287 changes: 287 additions & 0 deletions src/main/java/net/dv8tion/jda/api/utils/Once.java
Original file line number Diff line number Diff line change
@@ -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 <E> Type of the event listened to
*
* @see JDA#listenOnce(Class)
*/
public class Once<E extends GenericEvent> implements EventListener
{
private static final Logger LOG = JDALogger.getLog(Once.class);

private final JDA jda;
private final Class<E> eventType;
private final List<Predicate<? super E>> filters;
private final CompletableFuture<E> future;
private final GatewayTask<E> task;
private final ScheduledFuture<?> timeoutFuture;
private final Runnable timeoutCallback;

protected Once(JDA jda, Class<E> eventType, List<Predicate<? super E>> 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<E> createTask()
{
final GatewayTask<E> 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 <E> Type of the event listened to
*/
public static class Builder<E extends GenericEvent>
{
private final JDA jda;
private final Class<E> eventType;
private final List<Predicate<? super E>> 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<E> 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.
*
* <p>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<E> filter(@Nonnull Predicate<? super E> 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<E> 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<E> 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.
*
* <p>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<E> setTimeoutPool(@Nonnull ScheduledExecutorService timeoutPool)
{
Checks.notNull(timeoutPool, "Timeout pool");
this.timeoutPool = timeoutPool;
return this;
}

/**
* Starts listening for the event, once.
*
* <p>The task will be completed after all {@link #filter(Predicate) filters} return {@code true}.
*
* <p>Exceptions thrown in {@link Task#get() blocking} and {@link Task#onSuccess(Consumer) async} contexts includes:
* <ul>
* <li>{@link CancellationException} - When {@link Task#cancel()} is called</li>
* <li>{@link TimeoutException} - When the listener has expired</li>
* <li>Any exception thrown by the {@link #timeout(Duration, Runnable) timeout callback}</li>
* </ul>
*
* @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<E> subscribe(@Nonnull Consumer<E> callback)
{
final Once<E> once = new Once<>(jda, eventType, filters, timeoutCallback, timeout, timeoutPool);
jda.addEventListener(once);
return once.task.onSuccess(callback);
}
}
}
7 changes: 7 additions & 0 deletions src/main/java/net/dv8tion/jda/internal/JDAImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,13 @@ public List<Object> getRegisteredListeners()
return eventManager.getRegisteredListeners();
}

@Nonnull
@Override
public <E extends GenericEvent> Once.Builder<E> listenOnce(@Nonnull Class<E> eventType)
{
return new Once.Builder<>(this, eventType);
}

@Nonnull
@Override
public RestAction<List<Command>> retrieveCommands(boolean withLocalizations)
Expand Down

0 comments on commit 0f2e837

Please sign in to comment.