diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java index 1a05547d91..6960cca19f 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java @@ -51,10 +51,8 @@ public enum Features { Collection features = new ArrayList<>(); // Routines - // TODO This should be moved into some proper command system instead (see GH issue #235 - // which adds support for routines) - new ModAuditLogRoutine(jda, database).start(); - new TemporaryModerationRoutine(jda, actionsStore).start(); + features.add(new ModAuditLogRoutine(database)); + features.add(new TemporaryModerationRoutine(jda, actionsStore)); // Message receivers diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/Routine.java b/application/src/main/java/org/togetherjava/tjbot/commands/Routine.java new file mode 100644 index 0000000000..d4b69f55ac --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Routine.java @@ -0,0 +1,69 @@ +package org.togetherjava.tjbot.commands; + +import net.dv8tion.jda.api.JDA; +import org.jetbrains.annotations.NotNull; + +import java.util.concurrent.TimeUnit; + +/** + * Routines are executed on a reoccurring schedule by the core system. + *

+ * All routines have to implement this interface. A new routine can then be registered by adding it + * to {@link Features}. + *

+ *

+ * After registration, the system will automatically start and execute {@link #runRoutine(JDA)} on + * the schedule defined by {@link #createSchedule()}. + */ +public interface Routine extends Feature { + /** + * Triggered by the core system on the schedule defined by {@link #createSchedule()}. + * + * @param jda the JDA instance the bot is operating with + */ + void runRoutine(@NotNull JDA jda); + + /** + * Retrieves the schedule of this routine. Called by the core system once during the startup in + * order to execute the routine accordingly. + *

+ * Changes on the schedule returned by this method afterwards will not be picked up. + * + * @return the schedule of this routine + */ + @NotNull + Schedule createSchedule(); + + /** + * The schedule of routines. + * + * @param mode whether subsequent executions are executed at a fixed rate or are delayed, + * influences how {@link #duration} is interpreted + * @param initialDuration the time which the first execution of the routine is delayed + * @param duration the time all subsequent executions of the routine are delayed. Either + * measured before execution ({@link ScheduleMode#FIXED_RATE}) or after execution has + * finished ({@link ScheduleMode#FIXED_DELAY}). + * @param unit the time unit for both, {@link #initialDuration} and {@link #duration}, e.g. + * seconds + */ + record Schedule(@NotNull ScheduleMode mode, long initialDuration, long duration, + @NotNull TimeUnit unit) { + } + + + /** + * Whether subsequent executions of a routine are executed at a fixed rate or are delayed. + */ + enum ScheduleMode { + /** + * Executions are scheduled for a fixed rate, the time duration between executions is + * measured between their starting time. + */ + FIXED_RATE, + /** + * Executions are scheduled for a fixed delay, the time duration between executions is + * measured between after they have finished. + */ + FIXED_DELAY + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java index 78428742aa..215f766466 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/moderation/temp/TemporaryModerationRoutine.java @@ -7,6 +7,7 @@ import org.jetbrains.annotations.NotNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.Routine; import org.togetherjava.tjbot.commands.moderation.ActionRecord; import org.togetherjava.tjbot.commands.moderation.ModerationAction; import org.togetherjava.tjbot.commands.moderation.ModerationActionsStore; @@ -15,8 +16,6 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Collectors; @@ -26,19 +25,15 @@ * Routine that revokes temporary moderation actions, such as temporary bans, as listed by * {@link ModerationActionsStore}. *

- * The routine is started by using {@link #start()} and then automatically executes on a schedule. - *

* Revoked actions are compatible with {@link ModerationActionsStore} and commands such as * {@link org.togetherjava.tjbot.commands.moderation.UnbanCommand} and * {@link org.togetherjava.tjbot.commands.moderation.AuditCommand}. */ -public final class TemporaryModerationRoutine { +public final class TemporaryModerationRoutine implements Routine { private static final Logger logger = LoggerFactory.getLogger(TemporaryModerationRoutine.class); private final ModerationActionsStore actionsStore; private final JDA jda; - private final ScheduledExecutorService checkExpiredActionsService = - Executors.newSingleThreadScheduledExecutor(); private final Map typeToRevocableAction; /** @@ -57,6 +52,16 @@ public TemporaryModerationRoutine(@NotNull JDA jda, Collectors.toMap(RevocableModerationAction::getApplyType, Function.identity())); } + @Override + public void runRoutine(@NotNull JDA jda) { + checkExpiredActions(); + } + + @Override + public @NotNull Schedule createSchedule() { + return new Schedule(ScheduleMode.FIXED_DELAY, 5, 5, TimeUnit.MINUTES); + } + private void checkExpiredActions() { logger.debug("Checking expired temporary moderation actions to revoke..."); @@ -146,19 +151,6 @@ private void handleFailure(@NotNull Throwable failure, "Action type is not revocable: " + type); } - /** - * Starts the routine, automatically checking expired temporary moderation actions on a - * schedule. - */ - public void start() { - // TODO This should be registered at some sort of routine system instead (see GH issue #235 - // which adds support for routines) - // TODO The initial run has to be delayed until after the guild cache has been updated - // (during CommandSystem startup) - checkExpiredActionsService.scheduleWithFixedDelay(this::checkExpiredActions, 5, 5, - TimeUnit.MINUTES); - } - private record RevocationGroupIdentifier(long guildId, long targetId, @NotNull ModerationAction type) { static RevocationGroupIdentifier of(@NotNull ActionRecord actionRecord) { diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/system/BotCore.java b/application/src/main/java/org/togetherjava/tjbot/commands/system/BotCore.java index 5b32efa476..ce68ecde5f 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/system/BotCore.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/system/BotCore.java @@ -30,6 +30,7 @@ import java.util.*; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.function.Function; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -51,6 +52,8 @@ public final class BotCore extends ListenerAdapter implements SlashCommandProvid private static final Logger logger = LoggerFactory.getLogger(BotCore.class); private static final String RELOAD_COMMAND = "reload"; private static final ExecutorService COMMAND_SERVICE = Executors.newCachedThreadPool(); + private static final ScheduledExecutorService ROUTINE_SERVICE = + Executors.newScheduledThreadPool(5); private final Map nameToSlashCommands; private final ComponentIdParser componentIdParser; private final ComponentIdStore componentIdStore; @@ -81,6 +84,23 @@ public BotCore(@NotNull JDA jda, @NotNull Database database) { .map(EventReceiver.class::cast) .forEach(jda::addEventListener); + // Routines + features.stream() + .filter(Routine.class::isInstance) + .map(Routine.class::cast) + .forEach(routine -> { + Routine.Schedule schedule = routine.createSchedule(); + switch (schedule.mode()) { + case FIXED_RATE -> ROUTINE_SERVICE.scheduleAtFixedRate( + () -> routine.runRoutine(jda), schedule.initialDuration(), + schedule.duration(), schedule.unit()); + case FIXED_DELAY -> ROUTINE_SERVICE.scheduleWithFixedDelay( + () -> routine.runRoutine(jda), schedule.initialDuration(), + schedule.duration(), schedule.unit()); + default -> throw new AssertionError("Unsupported schedule mode"); + } + }); + // Slash commands nameToSlashCommands = features.stream() .filter(SlashCommand.class::isInstance) diff --git a/application/src/main/java/org/togetherjava/tjbot/routines/ModAuditLogRoutine.java b/application/src/main/java/org/togetherjava/tjbot/routines/ModAuditLogRoutine.java index 1db79270b9..aa498888ed 100644 --- a/application/src/main/java/org/togetherjava/tjbot/routines/ModAuditLogRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/routines/ModAuditLogRoutine.java @@ -15,6 +15,7 @@ import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.Routine; import org.togetherjava.tjbot.commands.moderation.ModerationUtils; import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.db.Database; @@ -26,7 +27,6 @@ import java.time.temporal.TemporalAccessor; import java.util.*; import java.util.List; -import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.function.BiFunction; @@ -36,13 +36,13 @@ /** * Routine that automatically checks moderator actions on a schedule and logs them to dedicated - * channels. Use {@link #start()} to trigger automatic execution of the routine. + * channels. *

* The routine is executed periodically, for example three times per day. When it runs, it checks * all moderator actions, such as user bans, kicks, muting or message deletion. Actions are then * logged to a dedicated channel, given by {@link Config#getModAuditLogChannelPattern()}. */ -public final class ModAuditLogRoutine { +public final class ModAuditLogRoutine implements Routine { private static final Logger logger = LoggerFactory.getLogger(ModAuditLogRoutine.class); private static final int CHECK_AUDIT_LOG_START_HOUR = 4; private static final int CHECK_AUDIT_LOG_EVERY_HOURS = 8; @@ -51,24 +51,19 @@ public final class ModAuditLogRoutine { private final Predicate isAuditLogChannel; private final Database database; - private final JDA jda; - private final ScheduledExecutorService checkAuditLogService = - Executors.newSingleThreadScheduledExecutor(); /** * Creates a new instance. * - * @param jda the JDA instance to use to send messages and retrieve information * @param database the database for memorizing audit log dates */ - public ModAuditLogRoutine(@NotNull JDA jda, @NotNull Database database) { + public ModAuditLogRoutine(@NotNull Database database) { Predicate isAuditLogChannelName = Pattern.compile(Config.getInstance().getModAuditLogChannelPattern()) .asMatchPredicate(); isAuditLogChannel = channel -> isAuditLogChannelName.test(channel.getName()); this.database = database; - this.jda = jda; } private static @NotNull RestAction handleAction(@NotNull Action action, @@ -105,7 +100,7 @@ private static boolean isSnowflakeAfter(@NotNull ISnowflake snowflake, } /** - * Schedules the given task for execution at a fixed rate (see + * Creates a schedule for execution at a fixed rate (see * {@link ScheduledExecutorService#scheduleAtFixedRate(Runnable, long, long, TimeUnit)}). The * initial first execution will be delayed to the next fixed time that matches the given period, * effectively making execution stable at fixed times of a day - regardless of when this method @@ -119,14 +114,11 @@ private static boolean isSnowflakeAfter(@NotNull ISnowflake snowflake, * Execution will also correctly roll over to the next day, for example if the method is * triggered at 21:30, the next execution will be at 4:00 the following day. * - * @param service the scheduler to use - * @param command the command to schedule * @param periodStartHour the hour of the day that marks the start of this period * @param periodHours the scheduling period in hours - * @return the instant when the command will be executed the first time + * @return the according schedule representing the planned execution */ - private static @NotNull Instant scheduleAtFixedRateFromNextFixedTime( - @NotNull ScheduledExecutorService service, @NotNull Runnable command, + private static @NotNull Schedule scheduleAtFixedRateFromNextFixedTime( @SuppressWarnings("SameParameterValue") int periodStartHour, @SuppressWarnings("SameParameterValue") int periodHours) { // NOTE This scheduler could be improved, for example supporting arbitrary periods (not just @@ -152,9 +144,8 @@ private static boolean isSnowflakeAfter(@NotNull ISnowflake snowflake, Instant now = Instant.now(); Instant nextFixedTime = computeClosestNextScheduleDate(now, fixedScheduleHours, periodHours); - service.scheduleAtFixedRate(command, ChronoUnit.SECONDS.between(now, nextFixedTime), + return new Schedule(ScheduleMode.FIXED_RATE, ChronoUnit.SECONDS.between(now, nextFixedTime), TimeUnit.HOURS.toSeconds(periodHours), TimeUnit.SECONDS); - return nextFixedTime; } private static @NotNull Instant computeClosestNextScheduleDate(@NotNull Instant instant, @@ -212,19 +203,21 @@ private static boolean isSnowflakeAfter(@NotNull ISnowflake snowflake, return Optional.of(handleAction(Action.MESSAGE_DELETION, entry)); } - /** - * Starts the routine, automatically checking the audit logs on a schedule. - */ - public void start() { - // TODO This should be registered at some sort of routine system instead (see GH issue #235 - // which adds support for routines) - Instant startInstant = scheduleAtFixedRateFromNextFixedTime(checkAuditLogService, - this::checkAuditLogsRoutine, CHECK_AUDIT_LOG_START_HOUR, + @Override + public void runRoutine(@NotNull JDA jda) { + checkAuditLogsRoutine(jda); + } + + @Override + public @NotNull Schedule createSchedule() { + Schedule schedule = scheduleAtFixedRateFromNextFixedTime(CHECK_AUDIT_LOG_START_HOUR, CHECK_AUDIT_LOG_EVERY_HOURS); - logger.info("Checking audit logs is scheduled for {}.", startInstant); + logger.info("Checking audit logs is scheduled for {}.", + Instant.now().plus(schedule.initialDuration(), schedule.unit().toChronoUnit())); + return schedule; } - private void checkAuditLogsRoutine() { + private void checkAuditLogsRoutine(@NotNull JDA jda) { logger.info("Checking audit logs of all guilds..."); jda.getGuildCache().forEach(guild -> { diff --git a/application/src/main/java/org/togetherjava/tjbot/routines/package-info.java b/application/src/main/java/org/togetherjava/tjbot/routines/package-info.java index 467a6a06da..325d70e560 100644 --- a/application/src/main/java/org/togetherjava/tjbot/routines/package-info.java +++ b/application/src/main/java/org/togetherjava/tjbot/routines/package-info.java @@ -2,8 +2,7 @@ * This package contains most routines of the bot. Routines can also be created in different * modules, if desired. *

- * Routines are actions that are executed periodically on a schedule. They are added and started - * manually in {@link org.togetherjava.tjbot.Application}. + * Routines are actions that are executed periodically on a schedule. They are added to the system + * in {@link org.togetherjava.tjbot.commands.Features}. */ -// TODO GH issue #235 will introduce a proper routine system package org.togetherjava.tjbot.routines;