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 76cdd928b7..d839a05a14 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java @@ -6,6 +6,7 @@ import org.togetherjava.tjbot.commands.basic.RoleSelectCommand; import org.togetherjava.tjbot.commands.basic.SuggestionsUpDownVoter; import org.togetherjava.tjbot.commands.basic.VcActivityCommand; +import org.togetherjava.tjbot.commands.bookmarks.*; import org.togetherjava.tjbot.commands.code.CodeMessageAutoDetection; import org.togetherjava.tjbot.commands.code.CodeMessageHandler; import org.togetherjava.tjbot.commands.code.CodeMessageManualDetection; @@ -67,6 +68,7 @@ private Features() { */ public static Collection createFeatures(JDA jda, Database database, Config config) { TagSystem tagSystem = new TagSystem(database); + BookmarksSystem bookmarksSystem = new BookmarksSystem(config, database); ModerationActionsStore actionsStore = new ModerationActionsStore(database); ModAuditLogWriter modAuditLogWriter = new ModAuditLogWriter(config); ScamHistoryStore scamHistoryStore = new ScamHistoryStore(database); @@ -90,6 +92,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features .add(new AutoPruneHelperRoutine(config, helpSystemHelper, modAuditLogWriter, database)); features.add(new HelpThreadAutoArchiver(helpSystemHelper)); + features.add(new LeftoverBookmarksCleanupRoutine(bookmarksSystem)); // Message receivers features.add(new TopHelpersMessageListener(database, config)); @@ -107,6 +110,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new RejoinModerationRoleListener(actionsStore, config)); features.add(new OnGuildLeaveCloseThreadListener(database)); features.add(new UserBannedDeleteRecentThreadsListener(database)); + features.add(new LeftoverBookmarksListener(bookmarksSystem)); // Message context commands @@ -139,6 +143,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new ModMailCommand(jda, config)); features.add(new HelpThreadCommand(config, helpSystemHelper)); features.add(new ReportCommand(config)); + features.add(new BookmarksCommand(bookmarksSystem)); // Mixtures features.add(new HelpThreadOverviewUpdater(config, helpSystemHelper)); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/BookmarksCommand.java b/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/BookmarksCommand.java new file mode 100644 index 0000000000..16dbbe34f8 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/BookmarksCommand.java @@ -0,0 +1,185 @@ +package org.togetherjava.tjbot.commands.bookmarks; + +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.SelectMenuInteractionEvent; +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.interactions.commands.build.SubcommandData; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.commands.CommandVisibility; +import org.togetherjava.tjbot.commands.SlashCommandAdapter; + +import java.util.List; +import java.util.Objects; + +/** + * The bookmarks command is used for managing and viewing bookmarks. A bookmark is a link to a help + * thread that can have a note so you can easily remember why you bookmarked a help thread. Writing + * to the database and showing the list/remove messages is not done by this class, that is handled + * by the {@link BookmarksSystem}. This class only checks if you are able to add a bookmark in the + * current channel and tells the {@link BookmarksSystem} to do the rest. + *

+ * Usage: + * + *

+ * /bookmarks add [note]
+ * /bookmarks list
+ * /bookmarks remove
+ * 
+ */ +public final class BookmarksCommand extends SlashCommandAdapter { + + private static final Logger logger = LoggerFactory.getLogger(BookmarksCommand.class); + + public static final String COMMAND_NAME = "bookmarks"; + public static final String SUBCOMMAND_ADD = "add"; + public static final String SUBCOMMAND_LIST = "list"; + public static final String SUBCOMMAND_REMOVE = "remove"; + public static final String ADD_BOOKMARK_NOTE_OPTION = "note"; + + private static final MessageEmbed NOT_A_HELP_THREAD_EMBED = + BookmarksSystem.createFailureEmbed("You can only bookmark help threads."); + + private static final MessageEmbed ALREADY_BOOKMARKED_EMBED = + BookmarksSystem.createFailureEmbed("You have already bookmarked this channel."); + + private static final MessageEmbed BOOKMARK_ADDED_EMBED = + BookmarksSystem.createSuccessEmbed("Your bookmark was added."); + + private static final MessageEmbed BOOKMARK_LIMIT_USER_EMBED = BookmarksSystem + .createFailureEmbed( + "You have exceeded your bookmarks limit of `%d`. Please delete some of your other bookmarks." + .formatted(BookmarksSystem.MAX_BOOKMARK_COUNT_PER_USER)); + + private static final MessageEmbed BOOKMARK_LIMIT_TOTAL_EMBED = BookmarksSystem + .createWarningEmbed( + """ + You cannot add a bookmark right now because the total amount of bookmarks has exceeded its limit. + Please wait a bit until some of them have been deleted or contact a moderator. + Sorry for the inconvenience. + """); + + private final BookmarksSystem bookmarksSystem; + private final BookmarksListRemoveHandler listRemoveHandler; + + /** + * Creates a new instance and registers every sub command. + * + * @param bookmarksSystem The {@link BookmarksSystem} to request pagination and manage bookmarks + */ + public BookmarksCommand(BookmarksSystem bookmarksSystem) { + super(COMMAND_NAME, "Bookmark help threads so that you can easily look them up again", + CommandVisibility.GLOBAL); + this.bookmarksSystem = bookmarksSystem; + listRemoveHandler = + new BookmarksListRemoveHandler(bookmarksSystem, this::generateComponentId); + + OptionData addNoteOption = new OptionData(OptionType.STRING, ADD_BOOKMARK_NOTE_OPTION, + "Your personal comment on this bookmark") + .setMaxLength(BookmarksSystem.MAX_NOTE_LENGTH) + .setRequired(false); + + SubcommandData addSubCommand = new SubcommandData(SUBCOMMAND_ADD, + "Bookmark this help thread, so that you can easily look it up again") + .addOptions(addNoteOption); + + SubcommandData listSubCommand = + new SubcommandData(SUBCOMMAND_LIST, "List all of your bookmarks"); + + SubcommandData removeSubCommand = + new SubcommandData(SUBCOMMAND_REMOVE, "Remove some of your bookmarks"); + + getData().addSubcommands(addSubCommand, listSubCommand, removeSubCommand); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + String subCommandName = Objects.requireNonNull(event.getSubcommandName()); + + switch (subCommandName) { + case SUBCOMMAND_ADD -> addBookmark(event); + case SUBCOMMAND_LIST -> listRemoveHandler.handleListRequest(event); + case SUBCOMMAND_REMOVE -> listRemoveHandler.handleRemoveRequest(event); + default -> throw new IllegalArgumentException("Unknown subcommand"); + } + } + + @Override + public void onButtonClick(ButtonInteractionEvent event, List args) { + listRemoveHandler.onButtonClick(event, args); + } + + @Override + public void onSelectMenuSelection(SelectMenuInteractionEvent event, List args) { + listRemoveHandler.onSelectMenuSelection(event, args); + } + + private void addBookmark(SlashCommandInteractionEvent event) { + long userID = event.getUser().getIdLong(); + long channelID = event.getChannel().getIdLong(); + String note = event.getOption(ADD_BOOKMARK_NOTE_OPTION, OptionMapping::getAsString); + + if (!handleCanAddBookmark(event)) { + return; + } + + bookmarksSystem.addBookmark(userID, channelID, note); + + sendResponse(event, BOOKMARK_ADDED_EMBED); + } + + private boolean handleCanAddBookmark(SlashCommandInteractionEvent event) { + MessageChannelUnion channel = event.getChannel(); + long channelID = channel.getIdLong(); + long userID = event.getUser().getIdLong(); + + if (!bookmarksSystem.isHelpThread(channel)) { + sendResponse(event, NOT_A_HELP_THREAD_EMBED); + return false; + } + + if (bookmarksSystem.didUserBookmarkChannel(userID, channelID)) { + sendResponse(event, ALREADY_BOOKMARKED_EMBED); + return false; + } + + long bookmarkCountTotal = bookmarksSystem.getTotalBookmarkCount(); + if (bookmarkCountTotal == BookmarksSystem.WARN_BOOKMARK_COUNT_TOTAL) { + logger.warn(""" + The bookmark limit will be reached soon (`{}/{}` bookmarks)! + If the limit is reached no new bookmarks can be added! + Please delete some bookmarks! + """, BookmarksSystem.WARN_BOOKMARK_COUNT_TOTAL, + BookmarksSystem.MAX_BOOKMARK_COUNT_TOTAL); + } + if (bookmarkCountTotal == BookmarksSystem.MAX_BOOKMARK_COUNT_TOTAL) { + logger.error(""" + The bookmark limit of `{}` has been reached! + No new bookmarks can be added anymore! + Please delete some bookmarks! + """, BookmarksSystem.MAX_BOOKMARK_COUNT_TOTAL); + } + if (bookmarkCountTotal > BookmarksSystem.MAX_BOOKMARK_COUNT_TOTAL) { + sendResponse(event, BOOKMARK_LIMIT_TOTAL_EMBED); + return false; + } + + long bookmarkCountUser = bookmarksSystem.getUserBookmarkCount(userID); + if (bookmarkCountUser >= BookmarksSystem.MAX_BOOKMARK_COUNT_PER_USER) { + sendResponse(event, BOOKMARK_LIMIT_USER_EMBED); + return false; + } + + return true; + } + + private void sendResponse(SlashCommandInteractionEvent event, MessageEmbed embed) { + event.replyEmbeds(embed).setEphemeral(true).queue(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/BookmarksListRemoveHandler.java b/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/BookmarksListRemoveHandler.java new file mode 100644 index 0000000000..e3ea0e4b2e --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/BookmarksListRemoveHandler.java @@ -0,0 +1,332 @@ +package org.togetherjava.tjbot.commands.bookmarks; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; +import net.dv8tion.jda.api.entities.emoji.Emoji; +import net.dv8tion.jda.api.events.interaction.command.GenericCommandInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.SelectMenuInteractionEvent; +import net.dv8tion.jda.api.interactions.components.ActionRow; +import net.dv8tion.jda.api.interactions.components.ComponentInteraction; +import net.dv8tion.jda.api.interactions.components.LayoutComponent; +import net.dv8tion.jda.api.interactions.components.buttons.Button; +import net.dv8tion.jda.api.interactions.components.selections.SelectMenu; +import net.dv8tion.jda.api.interactions.components.selections.SelectOption; + +import org.togetherjava.tjbot.commands.utils.MessageUtils; +import org.togetherjava.tjbot.db.generated.tables.records.BookmarksRecord; + +import java.awt.Color; +import java.util.*; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.UnaryOperator; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +final class BookmarksListRemoveHandler { + + private static final int ENTRIES_PER_PAGE = 10; + private static final Emoji BUTTON_PREV_EMOJI = Emoji.fromUnicode("⬅"); + private static final String BUTTON_PREV_NAME = "button-previous"; + private static final Emoji BUTTON_NEXT_EMOJI = Emoji.fromUnicode("➡"); + private static final String BUTTON_NEXT_NAME = "button-next"; + private static final String BUTTON_REMOVE_LABEL = "Delete selected bookmarks"; + private static final String BUTTON_REMOVE_NAME = "button-remove"; + private static final String SELECT_MENU_REMOVE_NAME = "select-menu-remove"; + + private static final MessageEmbed NO_BOOKMARKS_EMBED = + BookmarksSystem.createFailureEmbed("You don't have any bookmarks yet."); + + private final BookmarksSystem bookmarksSystem; + private final Function generateComponentId; + + BookmarksListRemoveHandler(BookmarksSystem bookmarksSystem, + Function generateComponentId) { + this.bookmarksSystem = bookmarksSystem; + this.generateComponentId = generateComponentId; + } + + void handleListRequest(GenericCommandInteractionEvent event) { + handlePaginatedRequest(event, RequestType.LIST); + } + + void handleRemoveRequest(GenericCommandInteractionEvent event) { + handlePaginatedRequest(event, RequestType.REMOVE); + } + + private void handlePaginatedRequest(GenericCommandInteractionEvent event, + RequestType requestType) { + JDA jda = event.getJDA(); + long userID = event.getUser().getIdLong(); + + List bookmarks = bookmarksSystem.getUsersBookmarks(userID); + if (bookmarks.isEmpty()) { + event.replyEmbeds(NO_BOOKMARKS_EMBED).setEphemeral(true).queue(); + return; + } + + MessageEmbed pageEmbed = generatePageEmbed(bookmarks, requestType, 0); + + Collection components = new ArrayList<>(); + components.add(generateNavigationComponent(requestType, 0)); + + if (requestType == RequestType.REMOVE) { + components + .addAll(generateRemoveComponents(jda, bookmarks, RequestType.REMOVE, 0, Set.of())); + } + + event.replyEmbeds(pageEmbed).setComponents(components).setEphemeral(true).queue(); + } + + private static MessageEmbed generatePageEmbed(List bookmarks, + RequestType requestType, int currentPageIndex) { + int lastPageIndex = getLastPageIndex(bookmarks); + + String title; + Color color; + switch (requestType) { + case LIST -> { + title = "Bookmarks List"; + color = BookmarksSystem.COLOR_SUCCESS; + } + case REMOVE -> { + title = "Remove Bookmarks"; + color = BookmarksSystem.COLOR_WARNING; + } + default -> throw new IllegalArgumentException("Unknown request type: " + requestType); + } + + StringJoiner descriptionJoiner = new StringJoiner("\n\n"); + + getPageEntries(bookmarks, currentPageIndex).forEach(pageEntry -> { + int bookmarkNumber = pageEntry.bookmarkNumber; + long channelID = pageEntry.bookmark.getChannelId(); + String note = pageEntry.bookmark.getNote(); + + StringJoiner entryJoiner = new StringJoiner("\n"); + + entryJoiner.add( + "**%d.** %s".formatted(bookmarkNumber, MessageUtils.mentionChannel(channelID))); + if (note != null && requestType != RequestType.REMOVE) { + entryJoiner.add("*%s*".formatted(note)); + } + + descriptionJoiner.add(entryJoiner.toString()); + }); + + String description = descriptionJoiner.toString(); + String footer = "Page %d/%d".formatted(currentPageIndex + 1, lastPageIndex + 1); + + return new EmbedBuilder().setTitle(title) + .setDescription(description) + .setFooter(footer) + .setColor(color) + .build(); + } + + void onButtonClick(ButtonInteractionEvent event, List args) { + Request request = Request.fromArgs(args); + long userID = event.getUser().getIdLong(); + + List bookmarks = bookmarksSystem.getUsersBookmarks(userID); + + int nextPageIndex = switch (request.componentName) { + case BUTTON_PREV_NAME -> clampPageIndex(bookmarks, request.pageToDisplayIndex - 1); + case BUTTON_NEXT_NAME -> clampPageIndex(bookmarks, request.pageToDisplayIndex + 1); + case BUTTON_REMOVE_NAME -> { + removeSelectedBookmarks(bookmarks, event, request); + yield clampPageIndex(bookmarks, request.pageToDisplayIndex); + } + default -> throw new IllegalArgumentException("Unknown button: " + request.componentName); + }; + + updatePagination(event, request.atPage(nextPageIndex), bookmarks); + } + + private void removeSelectedBookmarks(List bookmarks, + ComponentInteraction event, Request request) { + long userID = event.getUser().getIdLong(); + + Predicate isBookmarkSelectedForRemoval = + bookmark -> request.bookmarkIdsToRemove.contains(bookmark.getChannelId()); + + bookmarks.removeIf(isBookmarkSelectedForRemoval); + bookmarksSystem.removeBookmarks(userID, request.bookmarkIdsToRemove); + } + + void onSelectMenuSelection(SelectMenuInteractionEvent event, List args) { + Request request = Request.fromArgs(args); + + if (request.type != RequestType.REMOVE) { + throw new IllegalArgumentException( + "Only remove requests must have a menu, but got " + request.type); + } + if (!request.componentName.equals(SELECT_MENU_REMOVE_NAME)) { + throw new IllegalArgumentException( + "There should only be a single menu, but got " + request.componentName); + } + + Set selectedBookmarkIdsToRemove = event.getSelectedOptions() + .stream() + .map(SelectOption::getValue) + .map(Long::parseLong) + .collect(Collectors.toSet()); + request = request.withSelectedBookmarksToRemove(selectedBookmarkIdsToRemove); + + List bookmarks = + bookmarksSystem.getUsersBookmarks(event.getUser().getIdLong()); + int updatedPageIndex = clampPageIndex(bookmarks, request.pageToDisplayIndex); + + updatePagination(event, request.atPage(updatedPageIndex), bookmarks); + } + + private void updatePagination(ComponentInteraction event, Request request, + List bookmarks) { + if (bookmarks.isEmpty()) { + event.editMessageEmbeds(NO_BOOKMARKS_EMBED).setComponents().queue(); + return; + } + + MessageEmbed pageEmbed = + generatePageEmbed(bookmarks, request.type, request.pageToDisplayIndex); + + Collection components = new ArrayList<>(); + components.add(generateNavigationComponent(request.type, request.pageToDisplayIndex)); + if (request.type == RequestType.REMOVE) { + components.addAll(generateRemoveComponents(event.getJDA(), bookmarks, request.type, + request.pageToDisplayIndex, request.bookmarkIdsToRemove)); + } + + event.editMessageEmbeds(pageEmbed).setComponents(components).queue(); + } + + private LayoutComponent generateNavigationComponent(RequestType requestType, + int pageToDisplayIndex) { + UnaryOperator generateNavigationComponentId = name -> { + Request request = new Request(requestType, name, pageToDisplayIndex, Set.of()); + + return generateComponentId.apply(request.toArray()); + }; + + String buttonPrevId = generateNavigationComponentId.apply(BUTTON_PREV_NAME); + Button buttonPrev = Button.primary(buttonPrevId, BUTTON_PREV_EMOJI); + + String buttonNextId = generateNavigationComponentId.apply(BUTTON_NEXT_NAME); + Button buttonNext = Button.primary(buttonNextId, BUTTON_NEXT_EMOJI); + + return ActionRow.of(buttonPrev, buttonNext); + } + + private List generateRemoveComponents(JDA jda, + List bookmarks, RequestType requestType, + int pageToDisplayIndex, Set bookmarksToRemoveChannelIDs) { + List pageEntries = getPageEntries(bookmarks, pageToDisplayIndex); + + UnaryOperator generateRemoveComponentId = name -> { + Request request = + new Request(requestType, name, pageToDisplayIndex, bookmarksToRemoveChannelIDs); + + return generateComponentId.apply(request.toArray()); + }; + + List selectMenuRemoveOptions = pageEntries.stream().map(pageEntry -> { + ThreadChannel channel = jda.getThreadChannelById(pageEntry.bookmark.getChannelId()); + String channelIDString = String.valueOf(pageEntry.bookmark.getChannelId()); + int bookmarkNumber = pageEntry.bookmarkNumber; + + String label = channel != null ? "%d. %s".formatted(bookmarkNumber, channel.getName()) + : "Delete bookmark %d".formatted(bookmarkNumber); + + return SelectOption.of(label, channelIDString); + }).toList(); + + String selectMenuRemoveId = generateRemoveComponentId.apply(SELECT_MENU_REMOVE_NAME); + SelectMenu selectMenuRemove = SelectMenu.create(selectMenuRemoveId) + .setPlaceholder("Select bookmarks to delete") + .addOptions(selectMenuRemoveOptions) + .setDefaultValues(bookmarksToRemoveChannelIDs.stream().map(String::valueOf).toList()) + .setRequiredRange(0, selectMenuRemoveOptions.size()) + .build(); + + String buttonRemoveId = generateRemoveComponentId.apply(BUTTON_REMOVE_NAME); + Button buttonRemove = Button.danger(buttonRemoveId, BUTTON_REMOVE_LABEL) + .withDisabled(bookmarksToRemoveChannelIDs.isEmpty()); + + return List.of(ActionRow.of(selectMenuRemove), ActionRow.of(buttonRemove)); + } + + private static List getPageEntries(List bookmarks, + int pageIndex) { + int indexStart = pageIndex * ENTRIES_PER_PAGE; + int indexEndMax = bookmarks.size(); + int indexEnd = Math.min(indexStart + ENTRIES_PER_PAGE, indexEndMax); + + return bookmarks.subList(indexStart, indexEnd) + .stream() + .map(bookmark -> new PageEntry(bookmarks.indexOf(bookmark) + 1, bookmark)) + .toList(); + } + + private static int getLastPageIndex(List bookmarks) { + if (bookmarks.isEmpty()) { + return 0; + } + + return getPageOfBookmark(bookmarks.size() - 1); + } + + private static int getPageOfBookmark(int bookmarkIndex) { + return Math.floorDiv(bookmarkIndex, ENTRIES_PER_PAGE); + } + + private static int clampPageIndex(List bookmarks, int pageIndex) { + int maxPageIndex = getLastPageIndex(bookmarks); + + return Math.min(Math.max(0, pageIndex), maxPageIndex); + } + + private enum RequestType { + LIST, + REMOVE + } + + private record PageEntry(int bookmarkNumber, BookmarksRecord bookmark) { + } + + private record Request(RequestType type, String componentName, int pageToDisplayIndex, + Set bookmarkIdsToRemove) { + Request atPage(int pageIndex) { + return new Request(type, componentName, pageIndex, bookmarkIdsToRemove); + } + + Request withSelectedBookmarksToRemove(Set selectedBookmarkIdsToRemove) { + return new Request(type, componentName, pageToDisplayIndex, + selectedBookmarkIdsToRemove); + } + + static Request fromArgs(List args) { + RequestType requestType = RequestType.valueOf(args.get(0)); + String componentName = args.get(1); + int currentPageIndex = Integer.parseInt(args.get(2)); + + Set bookmarkIdsToRemove = Set.of(); + if (args.size() > 3) { + bookmarkIdsToRemove = + args.stream().skip(3).map(Long::parseLong).collect(Collectors.toSet()); + } + + return new Request(requestType, componentName, currentPageIndex, bookmarkIdsToRemove); + } + + String[] toArray() { + Stream primaryArgs = + Stream.of(type.name(), componentName, Integer.toString(pageToDisplayIndex)); + Stream secondaryArgs = bookmarkIdsToRemove.stream().map(String::valueOf); + + return Stream.concat(primaryArgs, secondaryArgs).toArray(String[]::new); + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/BookmarksSystem.java b/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/BookmarksSystem.java new file mode 100644 index 0000000000..4c2348f928 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/BookmarksSystem.java @@ -0,0 +1,145 @@ +package org.togetherjava.tjbot.commands.bookmarks; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; +import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion; + +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.tables.records.BookmarksRecord; + +import javax.annotation.Nullable; + +import java.awt.Color; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +import static org.togetherjava.tjbot.db.generated.tables.Bookmarks.BOOKMARKS; + +/** + * Maintains all bookmarks for all users and provides methods to create, query and remove them. Used + * by the other bookmarks classes. + */ +public final class BookmarksSystem { + + static final int MAX_BOOKMARK_COUNT_TOTAL = 1_000_000; + static final int WARN_BOOKMARK_COUNT_TOTAL = 900_000; + static final int MAX_BOOKMARK_COUNT_PER_USER = 500; + static final int MAX_NOTE_LENGTH = 150; + private static final Duration REMOVE_BOOKMARKS_AFTER_LEAVE_DELAY = Duration.ofDays(7); + + static final Color COLOR_SUCCESS = new Color(166, 218, 149); + static final Color COLOR_WARNING = new Color(245, 169, 127); + static final Color COLOR_FAILURE = new Color(238, 153, 160); + + private final Database database; + private final Predicate isOverviewChannelName; + + /** + * Creates a new instance of the bookmarks system. + * + * @param config The {@link Config} to get the overview channel pattern + * @param database The {@link Database} to store and retrieve bookmarks + */ + public BookmarksSystem(Config config, Database database) { + this.database = database; + + isOverviewChannelName = Pattern.compile(config.getHelpSystem().getOverviewChannelPattern()) + .asMatchPredicate(); + } + + boolean isHelpThread(MessageChannelUnion channel) { + if (channel.getType() != ChannelType.GUILD_PUBLIC_THREAD) { + return false; + } + + ThreadChannel threadChannel = channel.asThreadChannel(); + String parentChannelName = threadChannel.getParentMessageChannel().getName(); + + return isOverviewChannelName.test(parentChannelName); + } + + boolean didUserBookmarkChannel(long userID, long channelID) { + return database.read(context -> context.selectFrom(BOOKMARKS) + .where(BOOKMARKS.AUTHOR_ID.eq(userID), BOOKMARKS.CHANNEL_ID.eq(channelID)) + .limit(1) + .fetchOne() != null); + } + + void addBookmark(long authorID, long channelID, @Nullable String note) { + database.write(context -> context.newRecord(BOOKMARKS) + .setAuthorId(authorID) + .setChannelId(channelID) + .setCreatedAt(Instant.now()) + .setNote(note) + .setDeleteAt(null) + .insert()); + } + + List getUsersBookmarks(long authorID) { + return database.read(context -> context.selectFrom(BOOKMARKS) + .where(BOOKMARKS.AUTHOR_ID.eq(authorID)) + .orderBy(BOOKMARKS.CREATED_AT.desc()) + .fetch()); + } + + void removeBookmarks(long authorID, Set channelIDs) { + database.write(context -> context.deleteFrom(BOOKMARKS) + .where(BOOKMARKS.AUTHOR_ID.eq(authorID), BOOKMARKS.CHANNEL_ID.in(channelIDs)) + .execute()); + } + + int getTotalBookmarkCount() { + return database.read(context -> context.fetchCount(BOOKMARKS)); + } + + int getUserBookmarkCount(long authorID) { + return database + .read(context -> context.fetchCount(BOOKMARKS, BOOKMARKS.AUTHOR_ID.eq(authorID))); + } + + void startDeletionPeriodForUser(long authorID) { + Instant deleteAt = Instant.now().plus(REMOVE_BOOKMARKS_AFTER_LEAVE_DELAY); + + database.write(context -> context.update(BOOKMARKS) + .set(BOOKMARKS.DELETE_AT, deleteAt) + .where(BOOKMARKS.AUTHOR_ID.eq(authorID)) + .execute()); + } + + void cancelDeletionPeriodForUser(long authorID) { + database.write(context -> context.update(BOOKMARKS) + .setNull(BOOKMARKS.DELETE_AT) + .where(BOOKMARKS.AUTHOR_ID.eq(authorID)) + .execute()); + } + + void deleteLeftoverBookmarks() { + database.write(context -> context.deleteFrom(BOOKMARKS) + .where(BOOKMARKS.DELETE_AT.isNotNull(), BOOKMARKS.DELETE_AT.lessThan(Instant.now())) + .execute()); + } + + private static MessageEmbed createColoredEmbed(String content, Color color) { + return new EmbedBuilder().setDescription(content).setColor(color).build(); + } + + static MessageEmbed createSuccessEmbed(String content) { + return createColoredEmbed(content, COLOR_SUCCESS); + } + + static MessageEmbed createWarningEmbed(String content) { + return createColoredEmbed(content, COLOR_WARNING); + } + + static MessageEmbed createFailureEmbed(String content) { + return createColoredEmbed(content, COLOR_FAILURE); + } + +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/LeftoverBookmarksCleanupRoutine.java b/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/LeftoverBookmarksCleanupRoutine.java new file mode 100644 index 0000000000..a680d84240 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/LeftoverBookmarksCleanupRoutine.java @@ -0,0 +1,35 @@ +package org.togetherjava.tjbot.commands.bookmarks; + +import net.dv8tion.jda.api.JDA; + +import org.togetherjava.tjbot.commands.Routine; + +import java.util.concurrent.TimeUnit; + +/** + * Tells the bookmarks system to delete the old bookmarks of users that left the guild + */ +public final class LeftoverBookmarksCleanupRoutine implements Routine { + + private final BookmarksSystem bookmarksSystem; + + /** + * Creates a new instance. + * + * @param bookmarksSystem The {@link BookmarksSystem} to delete leftover bookmarks + */ + public LeftoverBookmarksCleanupRoutine(BookmarksSystem bookmarksSystem) { + this.bookmarksSystem = bookmarksSystem; + } + + @Override + public Schedule createSchedule() { + return new Schedule(ScheduleMode.FIXED_RATE, 0, 6, TimeUnit.HOURS); + } + + @Override + public void runRoutine(JDA jda) { + bookmarksSystem.deleteLeftoverBookmarks(); + } + +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/LeftoverBookmarksListener.java b/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/LeftoverBookmarksListener.java new file mode 100644 index 0000000000..b18f7ecb70 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/LeftoverBookmarksListener.java @@ -0,0 +1,40 @@ +package org.togetherjava.tjbot.commands.bookmarks; + +import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent; +import net.dv8tion.jda.api.events.guild.member.GuildMemberRemoveEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.jetbrains.annotations.NotNull; + +import org.togetherjava.tjbot.commands.EventReceiver; + +/** + * Initiates the bookmarks deletion period for a user when leaving the guild. When the user rejoins + * the guild the deletion period will be canceled + */ +public final class LeftoverBookmarksListener extends ListenerAdapter implements EventReceiver { + + private final BookmarksSystem bookmarksSystem; + + /** + * Creates a new instance. + * + * @param bookmarksSystem The {@link BookmarksSystem} to start or cancel the deletion period + */ + public LeftoverBookmarksListener(BookmarksSystem bookmarksSystem) { + this.bookmarksSystem = bookmarksSystem; + } + + @Override + public void onGuildMemberRemove(@NotNull GuildMemberRemoveEvent event) { + long userID = event.getUser().getIdLong(); + + bookmarksSystem.startDeletionPeriodForUser(userID); + } + + @Override + public void onGuildMemberJoin(@NotNull GuildMemberJoinEvent event) { + long userID = event.getUser().getIdLong(); + + bookmarksSystem.cancelDeletionPeriodForUser(userID); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/package-info.java b/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/package-info.java new file mode 100644 index 0000000000..8327cf37ab --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/bookmarks/package-info.java @@ -0,0 +1,12 @@ +/** + * This package offers all functionality for maintaining bookmarks. For example commands that let + * users bookmark a help thread, such as + * {@link org.togetherjava.tjbot.commands.bookmarks.BookmarksCommand}. + */ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.commands.bookmarks; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/utils/MessageUtils.java b/application/src/main/java/org/togetherjava/tjbot/commands/utils/MessageUtils.java index ff670b17f7..a517b9a81a 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/utils/MessageUtils.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/utils/MessageUtils.java @@ -122,6 +122,19 @@ private static RestAction mentionSlashCommand( }); } + /** + * Converts the id of a channel to a mentioned channel, which you can directly click on in + * Discord. + * + * @param channelId the id of the channel to mention + * @return Formatted string for the mentioned channel + */ + public static String mentionChannel(long channelId) { + // NOTE Hardcoded until JDA offers something like User.fromId(id).getAsMention() for + // channels as well + return "<#%d>".formatted(channelId); + } + /** * Abbreviates the given text if it is too long. *

diff --git a/application/src/main/resources/db/V12__Add_Bookmarks_System.sql b/application/src/main/resources/db/V12__Add_Bookmarks_System.sql new file mode 100644 index 0000000000..c07a765648 --- /dev/null +++ b/application/src/main/resources/db/V12__Add_Bookmarks_System.sql @@ -0,0 +1,10 @@ +CREATE TABLE bookmarks +( + author_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL, + note TEXT, + delete_at TIMESTAMP, + + PRIMARY KEY (author_id, channel_id) +)