diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Features.java b/application/src/main/java/org/togetherjava/tjbot/features/Features.java index c360bacdd1..f2867508f7 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -67,6 +67,8 @@ import org.togetherjava.tjbot.features.moderation.scam.ScamHistoryStore; import org.togetherjava.tjbot.features.moderation.temp.TemporaryModerationRoutine; import org.togetherjava.tjbot.features.projects.ProjectsThreadCreatedListener; +import org.togetherjava.tjbot.features.purge.PurgeCommand; +import org.togetherjava.tjbot.features.purge.PurgeMessagesByUserCommand; import org.togetherjava.tjbot.features.reminder.RemindRoutine; import org.togetherjava.tjbot.features.reminder.ReminderCommand; import org.togetherjava.tjbot.features.rss.RSSHandlerRoutine; @@ -219,6 +221,8 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new JShellCommand(jshellEval)); features.add(new MessageCommand()); features.add(new RewriteCommand(chatGptService)); + features.add(new PurgeCommand(modAuditLogWriter)); + features.add(new PurgeMessagesByUserCommand(modAuditLogWriter)); FeatureBlacklist> blacklist = blacklistConfig.normal(); return blacklist.filterStream(features.stream(), Object::getClass).toList(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeCommand.java new file mode 100644 index 0000000000..66398f8e1a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeCommand.java @@ -0,0 +1,187 @@ +package org.togetherjava.tjbot.features.purge; + +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.utils.TimeUtil; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.moderation.audit.ModAuditLogWriter; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +/** + * Slash command that bulk-deletes messages in a text channel posted after a given anchor message. + *

+ * The anchor message itself is preserved (deletion starts exclusively from messages newer than it). + * An optional {@code amount} option caps the number of messages deleted; when omitted, the command + * keeps deleting until no newer messages remain. + *

+ * Because this command is destructive, it presents an ephemeral confirmation dialog before any + * deletion runs, and rejects anchors older than {@link #MAX_ANCHOR_AGE} to avoid accidentally + * purging large swathes of channel history. Completed purges are written to the moderation audit + * log. + */ +public class PurgeCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(PurgeCommand.class); + private static final String CHANNEL_OPTION = "channel"; + private static final String MESSAGE_OPTION = "message-id"; + private static final String MINUTES_OPTION = "minutes"; + private static final String AMOUNT_OPTION = "amount"; + private static final Duration MAX_ANCHOR_AGE = Duration.ofDays(2); + + private final ModAuditLogWriter modAuditLogWriter; + + /** + * Constructs the command and registers its options ({@code channel}, optional + * {@code message-id}, optional {@code minutes}, optional {@code amount}). + * + * @param modAuditLogWriter used to record completed purges for moderator review + */ + public PurgeCommand(ModAuditLogWriter modAuditLogWriter) { + super("purge", "Deletes all messages in a channel after the given message id", + CommandVisibility.GUILD); + + this.modAuditLogWriter = modAuditLogWriter; + + getData() + .addOptions( + new OptionData(OptionType.CHANNEL, CHANNEL_OPTION, "The channel to purge", true) + .setChannelTypes(ChannelType.TEXT)) + .addOptions(new OptionData(OptionType.STRING, MESSAGE_OPTION, + "The message id to start purging from (exclusive)", false)) + .addOptions(new OptionData(OptionType.INTEGER, MINUTES_OPTION, + "Purge messages sent in the last N minutes (alternative to message-id)", false) + .setMinValue(1) + .setMaxValue(MAX_ANCHOR_AGE.toMinutes())) + .addOptions(new OptionData(OptionType.INTEGER, AMOUNT_OPTION, + "The amount of messages to delete (default: all)", false) + .setMinValue(1)); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + TextChannel channel = Objects.requireNonNull(event.getOption(CHANNEL_OPTION)) + .getAsChannel() + .asTextChannel(); + + OptionMapping messageOption = event.getOption(MESSAGE_OPTION); + OptionMapping minutesOption = event.getOption(MINUTES_OPTION); + + if ((messageOption == null) == (minutesOption == null)) { + event.reply("Provide exactly one of `message-id` or `minutes`.") + .setEphemeral(true) + .queue(); + return; + } + + ResolvedAnchor anchor = messageOption != null ? resolveMessageAnchor(event, messageOption) + : resolveMinutesAnchor(minutesOption); + + OptionMapping amountOption = event.getOption(AMOUNT_OPTION); + int amount = amountOption == null ? Integer.MAX_VALUE : amountOption.getAsInt(); + String amountLabel = amount == Integer.MAX_VALUE ? "all" : Integer.toString(amount); + + String description = "About to delete up to **%s** messages from %s, %s.".formatted( + amountLabel, channel.getAsMention(), Objects.requireNonNull(anchor).description()); + + String confirmId = generateComponentId(PurgeHelper.CONFIRM_ACTION, channel.getId(), + anchor.snowflake(), Integer.toString(amount)); + String cancelId = generateComponentId(PurgeHelper.CANCEL_ACTION); + + PurgeHelper.sendConfirmationDialog(event, "Confirm purge", description, confirmId, + cancelId); + } + + private @Nullable ResolvedAnchor resolveMessageAnchor(SlashCommandInteractionEvent event, + OptionMapping messageOption) { + String messageId = messageOption.getAsString(); + long anchorIdLong; + try { + anchorIdLong = Long.parseLong(messageId); + } catch (NumberFormatException _) { + event.reply("The provided message id is not a valid snowflake.") + .setEphemeral(true) + .queue(); + return null; + } + + Instant anchorCreatedAt = TimeUtil.getTimeCreated(anchorIdLong).toInstant(); + Duration anchorAge = Duration.between(anchorCreatedAt, Instant.now()); + if (anchorAge.compareTo(MAX_ANCHOR_AGE) > 0) { + event.reply( + "Refusing to purge: anchor message is older than %d days. Pick a more recent anchor." + .formatted(MAX_ANCHOR_AGE.toDays())) + .setEphemeral(true) + .queue(); + return null; + } + + return new ResolvedAnchor(messageId, "starting after message `%s` (sent )" + .formatted(messageId, anchorCreatedAt.getEpochSecond())); + } + + private ResolvedAnchor resolveMinutesAnchor(OptionMapping minutesOption) { + int minutes = minutesOption.getAsInt(); + Instant anchorCreatedAt = Instant.now().minus(Duration.ofMinutes(minutes)); + String snowflake = + Long.toUnsignedString(TimeUtil.getDiscordTimestamp(anchorCreatedAt.toEpochMilli())); + return new ResolvedAnchor(snowflake, + "sent in the last **%d** minute%s".formatted(minutes, minutes == 1 ? "" : "s")); + } + + private record ResolvedAnchor(String snowflake, String description) { + } + + @Override + public void onButtonClick(ButtonInteractionEvent event, List args) { + String action = args.getFirst(); + + if (PurgeHelper.CANCEL_ACTION.equals(action)) { + PurgeHelper.handleCancel(event); + return; + } + + if (!PurgeHelper.CONFIRM_ACTION.equals(action)) { + return; + } + + String channelId = args.get(1); + String messageId = args.get(2); + int amount = Integer.parseInt(args.get(3)); + + TextChannel channel = + Objects.requireNonNull(event.getGuild()).getTextChannelById(channelId); + if (channel == null) { + event.editMessage("That channel no longer exists.").setEmbeds().setComponents().queue(); + return; + } + + event.editMessage("Purging... this may take a while.").setEmbeds().setComponents().queue(); + + logger.info("Purge initiated by {} in channel {} starting from messageId {} (amount: {})", + event.getUser().getId(), channel.getName(), messageId, amount); + + PurgeHelper.purgeChannelMessages(channel, messageId, amount, 0, _ -> true, total -> { + event.getHook() + .editOriginal("Purge complete: deleted %d messages from %s.".formatted(total, + channel.getAsMention())) + .queue(); + + modAuditLogWriter.write("/purge", + "Deleted %d messages from %s".formatted(total, channel.getAsMention()), + event.getUser(), Instant.now(), Objects.requireNonNull(event.getGuild())); + }); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeHelper.java b/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeHelper.java new file mode 100644 index 0000000000..5399ccfd70 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeHelper.java @@ -0,0 +1,101 @@ +package org.togetherjava.tjbot.features.purge; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; +import net.dv8tion.jda.api.interactions.components.buttons.Button; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.Color; +import java.util.List; +import java.util.function.Predicate; + +/** + * Shared helpers used by the {@link PurgeCommand} family. Centralises the confirmation dialog and + * the recursive history-pagination + bulk-delete loop so each variant only owns its own input + * handling. + */ +final class PurgeHelper { + private static final Logger logger = LoggerFactory.getLogger(PurgeHelper.class); + + static final int BATCH_SIZE = 100; + static final String CONFIRM_ACTION = "confirm"; + static final String CANCEL_ACTION = "cancel"; + + private PurgeHelper() {} + + /** + * Replies to {@code event} with an ephemeral confirmation embed and a Confirm (danger) / Cancel + * (secondary) button row. + */ + static void sendConfirmationDialog(IReplyCallback event, String title, String description, + String confirmComponentId, String cancelComponentId) { + EmbedBuilder embed = + new EmbedBuilder().setTitle(title).setDescription(description).setColor(Color.RED); + + event.replyEmbeds(embed.build()) + .setEphemeral(true) + .addActionRow(Button.danger(confirmComponentId, "Confirm purge"), + Button.secondary(cancelComponentId, "Cancel")) + .queue(); + } + + /** + * Edits the originating message to indicate the purge was cancelled and removes the buttons. + */ + static void handleCancel(ButtonInteractionEvent event) { + event.editMessage("Purge cancelled.").setEmbeds().setComponents().queue(); + } + + /** + * Recursively deletes messages newer than {@code messageId} in {@code channel} that satisfy + * {@code filter}, up to {@code remaining} matches. + *

+ * Each call fetches a full {@link #BATCH_SIZE} batch via + * {@link MessageChannel#getHistoryAfter(String, int)}, filters it, deletes the matches with + * {@link MessageChannel#purgeMessages(List)}, then recurses using the newest message of the + * fetched batch as the next anchor. {@code onComplete} fires exactly once with the cumulative + * count of matches submitted for deletion. Fetch failures are logged and treated as the end of + * the channel. + * + * @param channel the channel to scan and purge + * @param messageId snowflake id of the anchor (exclusive lower bound) + * @param remaining maximum further matches that may still be deleted + * @param totalDeleted matches already deleted by prior recursive calls in this chain + * @param filter predicate selecting which fetched messages to delete + * @param onComplete callback invoked with the final cumulative count for this channel + */ + static void purgeChannelMessages(MessageChannel channel, String messageId, int remaining, + int totalDeleted, Predicate filter, PurgeResult onComplete) { + channel.getHistoryAfter(messageId, BATCH_SIZE).queue(history -> { + List fetched = history.getRetrievedHistory(); + if (fetched.isEmpty()) { + onComplete.run(totalDeleted); + return; + } + + List matches = fetched.stream().filter(filter).limit(remaining).toList(); + + if (!matches.isEmpty()) { + channel.purgeMessages(matches); + } + + int newTotal = totalDeleted + matches.size(); + int newRemaining = remaining - matches.size(); + + if (fetched.size() == BATCH_SIZE && newRemaining > 0) { + purgeChannelMessages(channel, fetched.getFirst().getId(), newRemaining, newTotal, + filter, onComplete); + } else { + onComplete.run(newTotal); + } + }, failure -> { + logger.warn("Failed to fetch history in channel {}: {}", channel.getName(), + failure.getMessage()); + onComplete.run(totalDeleted); + }); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeMessagesByUserCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeMessagesByUserCommand.java new file mode 100644 index 0000000000..6301423e0d --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeMessagesByUserCommand.java @@ -0,0 +1,271 @@ +package org.togetherjava.tjbot.features.purge; + +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.utils.TimeUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.moderation.audit.ModAuditLogWriter; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Slash command that deletes messages authored by a given user across every channel they have view + * access to within a recent time window. + *

+ * Because Discord exposes no "messages by user" endpoint, this command iterates each candidate + * channel and paginates its recent history, filtering by author. It is therefore noticeably slower + * than the single-channel {@link PurgeCommand} and intentionally caps its lookback at + * {@link #MAX_WINDOW}. + *

+ * Like {@link PurgeCommand} it shows a confirmation dialog before running and records completed + * runs in the moderation audit log. Active threads are scanned; archived threads are skipped. + */ +public class PurgeMessagesByUserCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(PurgeMessagesByUserCommand.class); + + private static final String USER_OPTION = "user"; + private static final String MINUTES_OPTION = "minutes"; + private static final String AMOUNT_OPTION = "amount"; + + private static final Duration MAX_WINDOW = Duration.ofHours(6); + private static final int PROGRESS_EDIT_EVERY_N_CHANNELS = 10; + + private final ModAuditLogWriter modAuditLogWriter; + + /** + * Constructs the command and registers its options ({@code user}, {@code minutes}, optional + * {@code amount}). + * + * @param modAuditLogWriter used to record completed purges for moderator review + */ + public PurgeMessagesByUserCommand(ModAuditLogWriter modAuditLogWriter) { + super("purge-messages-by-user", + "Deletes a user's recent messages across all channels they can access", + CommandVisibility.GUILD); + + this.modAuditLogWriter = modAuditLogWriter; + + getData() + .addOptions(new OptionData(OptionType.USER, USER_OPTION, + "The user whose messages to purge", true)) + .addOptions(new OptionData(OptionType.INTEGER, MINUTES_OPTION, + "Purge messages sent in the last N minutes", true) + .setMinValue(1) + .setMaxValue(MAX_WINDOW.toMinutes())) + .addOptions(new OptionData(OptionType.INTEGER, AMOUNT_OPTION, + "Global cap on the number of messages to delete (default: all)", false) + .setMinValue(1)); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + Guild guild = Objects.requireNonNull(event.getGuild()); + User targetUser = Objects.requireNonNull(event.getOption(USER_OPTION)).getAsUser(); + int minutes = Objects.requireNonNull(event.getOption(MINUTES_OPTION)).getAsInt(); + OptionMapping amountOption = event.getOption(AMOUNT_OPTION); + int amount = amountOption == null ? Integer.MAX_VALUE : amountOption.getAsInt(); + + Member targetMember = guild.getMember(targetUser); + if (targetMember == null) { + event.reply("That user is not a member of this guild.").setEphemeral(true).queue(); + return; + } + + List candidates = collectCandidateChannels(guild, targetMember); + if (candidates.isEmpty()) { + event + .reply("Found no channels where %s has access and the bot can manage messages." + .formatted(targetUser.getAsMention())) + .setEphemeral(true) + .queue(); + return; + } + + Instant anchorCreatedAt = Instant.now().minus(Duration.ofMinutes(minutes)); + String anchorSnowflake = + Long.toUnsignedString(TimeUtil.getDiscordTimestamp(anchorCreatedAt.toEpochMilli())); + + String amountLabel = amount == Integer.MAX_VALUE ? "all" : Integer.toString(amount); + String description = + "About to delete up to **%s** messages by %s sent in the last **%d** minute%s, across **%d** channel%s. This may take several minutes." + .formatted(amountLabel, targetUser.getAsMention(), minutes, + minutes == 1 ? "" : "s", candidates.size(), + candidates.size() == 1 ? "" : "s"); + + String confirmId = generateComponentId(PurgeHelper.CONFIRM_ACTION, targetUser.getId(), + anchorSnowflake, Integer.toString(amount)); + String cancelId = generateComponentId(PurgeHelper.CANCEL_ACTION); + + PurgeHelper.sendConfirmationDialog(event, "Confirm user purge", description, confirmId, + cancelId); + } + + @Override + public void onButtonClick(ButtonInteractionEvent event, List args) { + String action = args.getFirst(); + + if (PurgeHelper.CANCEL_ACTION.equals(action)) { + PurgeHelper.handleCancel(event); + return; + } + + if (!PurgeHelper.CONFIRM_ACTION.equals(action)) { + return; + } + + long targetUserId = Long.parseLong(args.get(1)); + String anchorSnowflake = args.get(2); + int amount = Integer.parseInt(args.get(3)); + + Guild guild = Objects.requireNonNull(event.getGuild()); + Member targetMember = guild.getMemberById(targetUserId); + if (targetMember == null) { + event.editMessage("That user is no longer a member of this guild.") + .setEmbeds() + .setComponents() + .queue(); + return; + } + + List candidates = collectCandidateChannels(guild, targetMember); + if (candidates.isEmpty()) { + event.editMessage("No accessible channels remain to scan.") + .setEmbeds() + .setComponents() + .queue(); + return; + } + + event + .editMessage("Purging across %d channels... this may take a while." + .formatted(candidates.size())) + .setEmbeds() + .setComponents() + .queue(); + + logger.info( + "User-purge initiated by {} targeting user {} ({} channels, amount: {}, anchor: {})", + event.getUser().getId(), targetUserId, candidates.size(), amount, anchorSnowflake); + + Map perChannel = new LinkedHashMap<>(); + PurgeContext ctx = + new PurgeContext(targetUserId, anchorSnowflake, perChannel, event, totalDeleted -> { + User targetUser = targetMember.getUser(); + event.getHook() + .editOriginal( + "Purge complete: deleted %d message%s from %s across %d channel%s." + .formatted(totalDeleted, totalDeleted == 1 ? "" : "s", + targetUser.getAsMention(), perChannel.size(), + perChannel.size() == 1 ? "" : "s")) + .queue(); + + modAuditLogWriter.write("/purge-messages-by-user", + buildAuditDescription(targetUser, totalDeleted, perChannel), + event.getUser(), Instant.now(), guild); + }); + + purgeAcrossChannels(ctx, candidates.iterator(), amount, 0, 0); + } + + private record PurgeContext(long targetUserId, String anchorSnowflake, + Map perChannel, ButtonInteractionEvent event, PurgeResult onComplete) { + } + + private List collectCandidateChannels(Guild guild, Member target) { + Member bot = guild.getSelfMember(); + List result = new ArrayList<>(); + + guild.getTextChannelCache().forEach(channel -> { + if (target.hasAccess(channel) + && bot.hasPermission(channel, Permission.MESSAGE_MANAGE)) { + result.add(channel); + } + }); + guild.getNewsChannelCache().forEach(channel -> { + if (target.hasAccess(channel) + && bot.hasPermission(channel, Permission.MESSAGE_MANAGE)) { + result.add(channel); + } + }); + guild.getThreadChannelCache().forEach(thread -> { + if (thread.isArchived()) { + return; + } + if (target.hasAccess(thread) && bot.hasPermission(thread, Permission.MESSAGE_MANAGE)) { + result.add(thread); + } + }); + + return result; + } + + private void purgeAcrossChannels(PurgeContext ctx, Iterator channels, + int remaining, int totalDeleted, int channelsProcessed) { + if (!channels.hasNext() || remaining <= 0) { + ctx.onComplete().run(totalDeleted); + return; + } + + GuildMessageChannel channel = channels.next(); + long targetUserId = ctx.targetUserId(); + + PurgeHelper.purgeChannelMessages(channel, ctx.anchorSnowflake(), remaining, 0, + m -> m.getAuthor().getIdLong() == targetUserId, channelDeleted -> { + if (channelDeleted > 0) { + ctx.perChannel().put(channel.getName(), channelDeleted); + } + + int newTotal = totalDeleted + channelDeleted; + int newRemaining = remaining - channelDeleted; + int newProcessed = channelsProcessed + 1; + + if (newProcessed % PROGRESS_EDIT_EVERY_N_CHANNELS == 0) { + ctx.event() + .getHook() + .editOriginal( + "Purging... %d messages deleted so far.".formatted(newTotal)) + .queue(); + } + + purgeAcrossChannels(ctx, channels, newRemaining, newTotal, newProcessed); + }); + } + + private static String buildAuditDescription(User target, int total, + Map perChannel) { + StringBuilder sb = new StringBuilder(); + sb.append("Deleted ") + .append(total) + .append(" messages by ") + .append(target.getAsMention()) + .append(" (`") + .append(target.getId()) + .append("`)."); + + if (!perChannel.isEmpty()) { + sb.append("\nBy channel:"); + perChannel.forEach( + (name, count) -> sb.append("\n• #").append(name).append(": ").append(count)); + } + return sb.toString(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeResult.java b/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeResult.java new file mode 100644 index 0000000000..b4a782388a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeResult.java @@ -0,0 +1,16 @@ +package org.togetherjava.tjbot.features.purge; + +/** + * Callback invoked by {@link PurgeCommand} once a purge operation finishes, carrying the total + * number of messages that were submitted for deletion. + */ +@FunctionalInterface +public interface PurgeResult { + /** + * Called when the purge completes. + * + * @param totalDeleted the cumulative number of messages submitted for deletion across all + * batches of the purge + */ + void run(int totalDeleted); +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/purge/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/purge/package-info.java new file mode 100644 index 0000000000..cc5c95ab4c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/purge/package-info.java @@ -0,0 +1,7 @@ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.purge; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault;