From 3d73daa16d0ea0fe46f1bd3088d56c5ccf113175 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 8 Dec 2024 18:49:20 +0100 Subject: [PATCH 001/168] add missing native hints for embeds --- .../java/net/discordjug/javabot/RuntimeHintsConfiguration.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java b/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java index bf1560607..2633932e1 100644 --- a/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java +++ b/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java @@ -52,7 +52,7 @@ //needs to be serialized for channel managers etc PermOverrideData.class, //ensure that webhook embed authors can be serialized - WebhookEmbed.EmbedAuthor.class + WebhookEmbed.EmbedAuthor.class, WebhookEmbed.EmbedField.class, WebhookEmbed.EmbedFooter.class, WebhookEmbed.EmbedTitle.class }) public class RuntimeHintsConfiguration implements RuntimeHintsRegistrar { From 768151ebe089151bc0f668862e7219fb126cf379 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 8 Dec 2024 18:57:35 +0100 Subject: [PATCH 002/168] use previous effective name for old name --- .../net/discordjug/javabot/listener/PingableNameListener.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/listener/PingableNameListener.java b/src/main/java/net/discordjug/javabot/listener/PingableNameListener.java index c19bdc29a..6483995ff 100644 --- a/src/main/java/net/discordjug/javabot/listener/PingableNameListener.java +++ b/src/main/java/net/discordjug/javabot/listener/PingableNameListener.java @@ -69,7 +69,7 @@ private void checkNickname(Member member) { * @param member The Member whose name should be changed. */ private void changeName(Member member) { - String oldName = member.getNickname(); + String oldName = member.getEffectiveName(); String newName = generateRandomName(); member.modifyNickname(newName.substring(0, Math.min(31, newName.length()))).queue(); member.getUser().openPrivateChannel() From 111ac57ae58b9d140b2a18b66039c099650c3c89 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 8 Dec 2024 20:32:48 +0100 Subject: [PATCH 003/168] graceful shutdown on redeploy --- .../net/discordjug/javabot/SpringConfig.java | 21 +++++-------- .../javabot/data/h2db/DbHelper.java | 31 ++++++++++++------- .../staff_commands/RedeployCommand.java | 24 ++++++++++++-- .../javabot/tasks/PresenceUpdater.java | 12 ++++--- 4 files changed, 56 insertions(+), 32 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/SpringConfig.java b/src/main/java/net/discordjug/javabot/SpringConfig.java index 1a5168a47..73bdefe2c 100644 --- a/src/main/java/net/discordjug/javabot/SpringConfig.java +++ b/src/main/java/net/discordjug/javabot/SpringConfig.java @@ -5,8 +5,6 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; -import javax.sql.DataSource; - import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -19,7 +17,6 @@ import net.discordjug.javabot.annotations.PreRegisteredListener; import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.config.SystemsConfig; -import net.discordjug.javabot.data.h2db.DbHelper; import net.discordjug.javabot.tasks.PresenceUpdater; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDABuilder; @@ -36,16 +33,8 @@ @RequiredArgsConstructor public class SpringConfig { @Bean - PresenceUpdater standardActivityPresenceUpdater() { - return PresenceUpdater.standardActivities(); - } - - @Bean - DataSource dataSource(BotConfig config) { - if (config.getSystems().getJdaBotToken().isEmpty()) { - throw new RuntimeException("JDA Token not set. Stopping Bot..."); - } - return DbHelper.initDataSource(config); + PresenceUpdater standardActivityPresenceUpdater(ScheduledExecutorService threadPool) { + return PresenceUpdater.standardActivities(threadPool); } @Bean @@ -95,6 +84,10 @@ DIH4JDA initializeDIH4JDA(JDA jda) throws DIH4JDAException { @Bean BotConfig botConfig() { - return new BotConfig(Path.of("config")); + BotConfig botConfig = new BotConfig(Path.of("config")); + if (botConfig.getSystems().getJdaBotToken().isEmpty()) { + throw new RuntimeException("JDA Token not set. Stopping Bot..."); + } + return botConfig; } } diff --git a/src/main/java/net/discordjug/javabot/data/h2db/DbHelper.java b/src/main/java/net/discordjug/javabot/data/h2db/DbHelper.java index e351dc98d..b18ceb95c 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/DbHelper.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/DbHelper.java @@ -2,8 +2,7 @@ import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; - -import lombok.Getter; +import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.discordjug.javabot.data.config.BotConfig; @@ -12,6 +11,7 @@ import org.h2.tools.Server; import org.jetbrains.annotations.NotNull; +import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Service; import java.io.IOException; @@ -32,12 +32,12 @@ /** * Class that provides helper methods for dealing with the database. */ -@Getter @Slf4j @Service @RequiredArgsConstructor public class DbHelper { - private final DataSource dataSource; + private Server server; + private HikariDataSource ds; /** * Initializes the data source that'll be used throughout the bot to access @@ -48,12 +48,12 @@ public class DbHelper { * @throws IllegalStateException If an error occurs and we're unable to * start the database. */ - public static @NotNull HikariDataSource initDataSource(@NotNull BotConfig config) { + @Bean + DataSource initDataSource(@NotNull BotConfig config) { // Determine if we need to initialize the schema, before starting up the server. boolean shouldInitSchema = shouldInitSchema(config.getSystems().getHikariConfig().getJdbcUrl()); // Now that we have remembered whether we need to initialize the schema, start up the server. - Server server; try { System.setProperty("h2.bindAddress", "127.0.0.1"); server = Server.createTcpServer("-tcpPort", "9122", "-ifNotExists").start(); @@ -66,12 +66,8 @@ public class DbHelper { hikariConfig.setJdbcUrl(hikariConfigSource.getJdbcUrl()); hikariConfig.setMaximumPoolSize(hikariConfigSource.getMaximumPoolSize()); hikariConfig.setLeakDetectionThreshold(hikariConfigSource.getLeakDetectionThreshold()); - HikariDataSource ds = new HikariDataSource(hikariConfig); + ds = new HikariDataSource(hikariConfig); // Add a shutdown hook to close down the datasource and server when the JVM terminates. - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - ds.close(); - server.stop(); - })); if (shouldInitSchema) { try { initializeSchema(ds); @@ -124,4 +120,17 @@ public static void initializeSchema(DataSource dataSource) throws IOException, S log.info("Successfully initialized H2 database."); } } + + /** + * Closes the database (server) when the application is shut down. + */ + @PreDestroy + public void stop() { + if (ds != null) { + ds.close(); + } + if (server != null) { + server.stop(); + } + } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/RedeployCommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/RedeployCommand.java index 5b3dfb08e..8ce1c84d3 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/RedeployCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/RedeployCommand.java @@ -1,6 +1,10 @@ package net.discordjug.javabot.systems.staff_commands; import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; + +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + import lombok.extern.slf4j.Slf4j; import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.h2db.message_cache.MessageCache; @@ -12,6 +16,8 @@ import net.dv8tion.jda.api.interactions.commands.build.Commands; import org.jetbrains.annotations.NotNull; +import org.springframework.boot.SpringApplication; +import org.springframework.context.ConfigurableApplicationContext; /** *

This class represents the /redeploy command.

@@ -26,15 +32,21 @@ public class RedeployCommand extends SlashCommand { private final MessageCache messageCache; private final BotConfig botConfig; + private final ScheduledExecutorService asyncPool; + private final ConfigurableApplicationContext applicationContext; /** * The constructor of this class, which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SlashCommandData}. * @param messageCache A service managing recent messages * @param botConfig The main configuration of the bot + * @param asyncPool The thread pool used for asynchronous operations + * @param applicationContext The spring application context */ - public RedeployCommand(MessageCache messageCache, BotConfig botConfig) { + public RedeployCommand(MessageCache messageCache, BotConfig botConfig, ScheduledExecutorService asyncPool, ConfigurableApplicationContext applicationContext) { this.messageCache = messageCache; this.botConfig=botConfig; + this.asyncPool = asyncPool; + this.applicationContext = applicationContext; setCommandData(Commands.slash("redeploy", "(ADMIN-ONLY) Makes the bot redeploy.") .setDefaultPermissions(DefaultMemberPermissions.DISABLED) .setGuildOnly(true) @@ -51,6 +63,14 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { log.warn("Redeploying... Requested by: " + UserUtils.getUserTag(event.getUser())); event.reply("**Redeploying...** This may take some time.").queue(); messageCache.synchronize(); - System.exit(0); + asyncPool.shutdownNow(); + try { + asyncPool.awaitTermination(3, TimeUnit.SECONDS); + event.getJDA().shutdown(); + event.getJDA().awaitShutdown(3, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + SpringApplication.exit(applicationContext, () -> 0); } } \ No newline at end of file diff --git a/src/main/java/net/discordjug/javabot/tasks/PresenceUpdater.java b/src/main/java/net/discordjug/javabot/tasks/PresenceUpdater.java index 09ff9c7d5..7f9e3be05 100644 --- a/src/main/java/net/discordjug/javabot/tasks/PresenceUpdater.java +++ b/src/main/java/net/discordjug/javabot/tasks/PresenceUpdater.java @@ -12,7 +12,6 @@ import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.function.Function; @@ -32,7 +31,7 @@ public class PresenceUpdater extends ListenerAdapter { * The executor that is responsible for the scheduled updates of the bot's * presence data. */ - private final ScheduledExecutorService threadPool = Executors.newSingleThreadScheduledExecutor(); + private final ScheduledExecutorService threadPool; /** * A list of functions that take a reference to the bot's JDA client, and @@ -64,24 +63,27 @@ public class PresenceUpdater extends ListenerAdapter { * @param activities The list of activity-producing functions. * @param delay The amount of time the updater should wait before updating the activity. * @param delayUnit The unit of time that {@link PresenceUpdater#delay} is counted in. + * @param threadPool The thread pool to use for updating presences */ - public PresenceUpdater(List> activities, long delay, TimeUnit delayUnit) { + public PresenceUpdater(List> activities, long delay, TimeUnit delayUnit, ScheduledExecutorService threadPool) { this.activities = new CopyOnWriteArrayList<>(activities); this.delay = delay; this.delayUnit = delayUnit; + this.threadPool = threadPool; } /** * A list of standard Activities. * + * @param threadPool The thread pool to use for updating activities * @return A pre-built implementation of the {@link PresenceUpdater} that * has all the necessary properties defined to reasonable defaults. */ - public static PresenceUpdater standardActivities() { + public static PresenceUpdater standardActivities(ScheduledExecutorService threadPool) { return new PresenceUpdater(List.of( jda -> Activity.watching(String.format("%s members", jda.getGuilds().stream().mapToLong(Guild::getMemberCount).sum())), jda -> Activity.customStatus("Use /report, 'Report User' or 'Report Message' to report disruptive behaviour!") - ), 35, TimeUnit.SECONDS); + ), 35, TimeUnit.SECONDS, threadPool); } /** From aebc8add879fc949a4fd7e1563c5a33a79d5dc98 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 29 Dec 2024 18:39:42 +0100 Subject: [PATCH 004/168] move QOTW points check after summation --- .../javabot/systems/qotw/dao/QuestionPointsRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/dao/QuestionPointsRepository.java b/src/main/java/net/discordjug/javabot/systems/qotw/dao/QuestionPointsRepository.java index 0bc1b8689..c13f4f0c2 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/dao/QuestionPointsRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/dao/QuestionPointsRepository.java @@ -98,7 +98,7 @@ public List sortByPoints(LocalDate startDate) throws DataAccessExce * @throws DataAccessException If an error occurs. */ public List getTopAccounts(LocalDate startDate, int page, int size) throws DataAccessException { - return jdbcTemplate.query("SELECT user_id, SUM(points) FROM qotw_points WHERE obtained_at >= ? AND points > 0 GROUP BY user_id ORDER BY SUM(points) DESC, user_id ASC LIMIT ? OFFSET ?", + return jdbcTemplate.query("SELECT user_id, SUM(points) FROM qotw_points WHERE obtained_at >= ? GROUP BY user_id HAVING SUM(points) > 0 ORDER BY SUM(points) DESC, user_id ASC LIMIT ? OFFSET ?", (rs,row)->this.read(rs), startDate, size, Math.max(0, (page * size) - size)); } From 9a61943e3621cf076b77f0ff99fd90612bfa2025 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 30 Dec 2024 12:16:02 +0100 Subject: [PATCH 005/168] improve QOTW messages sent to users --- .../notification/QOTWNotificationService.java | 24 +++++++++++-------- .../qotw_points/ChangePointsSubcommand.java | 6 ++--- .../DecrementPointsSubcommand.java | 2 +- .../IncrementPointsSubcommand.java | 13 ++++++---- .../qotw/jobs/QOTWCloseSubmissionsJob.java | 3 +++ .../qotw/submissions/SubmissionManager.java | 4 ++-- .../qotw/submissions/SubmissionStatus.java | 6 ++++- 7 files changed, 36 insertions(+), 22 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/notification/QOTWNotificationService.java b/src/main/java/net/discordjug/javabot/systems/notification/QOTWNotificationService.java index 8004f4654..36846aaab 100644 --- a/src/main/java/net/discordjug/javabot/systems/notification/QOTWNotificationService.java +++ b/src/main/java/net/discordjug/javabot/systems/notification/QOTWNotificationService.java @@ -11,7 +11,7 @@ import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.entities.User; - +import net.dv8tion.jda.api.entities.channel.concrete.ForumChannel; import org.jetbrains.annotations.NotNull; import org.springframework.dao.DataAccessException; @@ -47,8 +47,8 @@ public void sendBestAnswerNotification() { notificationService.withUser(user).sendDirectMessage(c -> c.sendMessageEmbeds(buildBestAnswerEmbed(account.getPoints()))); } - public void sendAccountIncrementedNotification() { - notificationService.withUser(user).sendDirectMessage(c -> c.sendMessageEmbeds(buildAccountIncrementEmbed(account.getPoints()))); + public void sendAccountIncrementedNotification(ForumChannel acceptedAnswerForumChannel) { + notificationService.withUser(user).sendDirectMessage(c -> c.sendMessageEmbeds(buildAccountIncrementEmbed(account.getPoints(), acceptedAnswerForumChannel))); } public void sendAccountDecrementedNotification() { @@ -59,15 +59,17 @@ public void sendAccountDecrementedNotification() { * Sends a "your submission was declined"-notification to the {@link QOTWNotificationService#user}. * * @param status The {@link SubmissionStatus}. Must NOT be {@link SubmissionStatus#ACCEPT} or {@link SubmissionStatus#ACCEPT_BEST} + * @param acceptedAnswerForumChannel The forum channel containing accepted answers to the QOTW */ - public void sendSubmissionDeclinedEmbed(SubmissionStatus status) { + public void sendSubmissionDeclinedEmbed(SubmissionStatus status, ForumChannel acceptedAnswerForumChannel) { String reason = switch (status) { case DECLINE_WRONG_ANSWER -> "Wrong answer"; case DECLINE_TOO_SHORT -> "Too short"; case DECLINE_EMPTY -> "Empty submission"; - default -> throw new IllegalArgumentException("Invalid decline status: " + status); + case DECLINE_PLAGIARISM -> "Plagiarism / AI usage"; + case ACCEPT, ACCEPT_BEST -> throw new IllegalArgumentException("Invalid decline status: " + status); }; - notificationService.withUser(user).sendDirectMessage(c -> c.sendMessageEmbeds(buildSubmissionDeclinedEmbed(reason))); + notificationService.withUser(user).sendDirectMessage(c -> c.sendMessageEmbeds(buildSubmissionDeclinedEmbed(reason, acceptedAnswerForumChannel))); } private @NotNull EmbedBuilder buildQOTWNotificationEmbed() { @@ -87,14 +89,15 @@ public void sendSubmissionDeclinedEmbed(SubmissionStatus status) { .build(); } - private @NotNull MessageEmbed buildAccountIncrementEmbed(long points) { + private @NotNull MessageEmbed buildAccountIncrementEmbed(long points, ForumChannel acceptedAnswerForumChannel) { return buildQOTWNotificationEmbed() .setColor(Responses.Type.SUCCESS.getColor()) .setDescription(String.format( """ Your submission was accepted! %s + You can view accepted answers in %s. You've been granted **`1 QOTW-Point`**! (monthly total: %s)""", - systemsConfig.getEmojiConfig().getSuccessEmote(guild.getJDA()), points)) + systemsConfig.getEmojiConfig().getSuccessEmote(guild.getJDA()), acceptedAnswerForumChannel.getJumpUrl(), points)) .build(); } @@ -109,14 +112,15 @@ public void sendSubmissionDeclinedEmbed(SubmissionStatus status) { .build(); } - private @NotNull MessageEmbed buildSubmissionDeclinedEmbed(String reason) { + private @NotNull MessageEmbed buildSubmissionDeclinedEmbed(String reason, ForumChannel acceptedAnswerForumChannel) { return this.buildQOTWNotificationEmbed() .setColor(Responses.Type.ERROR.getColor()) .setDescription(String.format(""" Hey %s, Your QOTW-Submission was **declined** for the following reason: `%s`. + You can view accepted answers in %s. However, you can try your luck again next week!""", - user.getAsMention(), reason)) + user.getAsMention(), reason, acceptedAnswerForumChannel.getJumpUrl())) .build(); } diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/commands/qotw_points/ChangePointsSubcommand.java b/src/main/java/net/discordjug/javabot/systems/qotw/commands/qotw_points/ChangePointsSubcommand.java index 430d524ed..032e9ef7b 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/commands/qotw_points/ChangePointsSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/commands/qotw_points/ChangePointsSubcommand.java @@ -71,13 +71,13 @@ private void sendNotifications(SlashCommandInteractionEvent event, Member member MessageEmbed embed = buildIncrementEmbed(member.getUser(), points); notificationService.withGuild(event.getGuild()).sendToModerationLog(c -> c.sendMessageEmbeds(embed)); if (!quiet) { - sendUserNotification(notificationService.withQOTW(event.getGuild(), member.getUser())); + sendUserNotification(notificationService.withQOTW(event.getGuild(), member.getUser()), member); } event.getHook().sendMessageEmbeds(embed).queue(); } - protected abstract void sendUserNotification(@NotNull QOTWNotificationService notificationService); + protected abstract void sendUserNotification(@NotNull QOTWNotificationService notificationService, Member member); protected @NotNull MessageEmbed buildIncrementEmbed(@NotNull User user, long points) { return createIncrementEmbedBuilder(user, points) @@ -88,7 +88,7 @@ private void sendNotifications(SlashCommandInteractionEvent event, Member member * Creates an {@link EmbedBuilder} for the notification embed. * @param user The user whose account is incremented * @param points The new total number of points of the user - * @return The created {@link EmbedBuilder} for creating the noticiation embed + * @return The created {@link EmbedBuilder} for creating the notification embed */ protected @NotNull EmbedBuilder createIncrementEmbedBuilder(User user, long points) { return new EmbedBuilder() diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/commands/qotw_points/DecrementPointsSubcommand.java b/src/main/java/net/discordjug/javabot/systems/qotw/commands/qotw_points/DecrementPointsSubcommand.java index 26892d18d..c5593430a 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/commands/qotw_points/DecrementPointsSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/commands/qotw_points/DecrementPointsSubcommand.java @@ -32,7 +32,7 @@ protected int getIncrementCount(Member targetMember, SlashCommandInteractionEven } @Override - protected void sendUserNotification(@NotNull QOTWNotificationService notificationService) { + protected void sendUserNotification(@NotNull QOTWNotificationService notificationService, Member member) { notificationService.sendAccountDecrementedNotification(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/commands/qotw_points/IncrementPointsSubcommand.java b/src/main/java/net/discordjug/javabot/systems/qotw/commands/qotw_points/IncrementPointsSubcommand.java index 194803648..ecf7aab56 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/commands/qotw_points/IncrementPointsSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/commands/qotw_points/IncrementPointsSubcommand.java @@ -1,7 +1,7 @@ package net.discordjug.javabot.systems.qotw.commands.qotw_points; import org.jetbrains.annotations.NotNull; - +import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.systems.notification.NotificationService; import net.discordjug.javabot.systems.notification.QOTWNotificationService; import net.discordjug.javabot.systems.qotw.QOTWPointsService; @@ -15,9 +15,12 @@ * This Subcommand allows staff-members to increment the QOTW-points of any user. */ public class IncrementPointsSubcommand extends ChangePointsSubcommand { - - public IncrementPointsSubcommand(QOTWPointsService pointsService, NotificationService notificationService) { + + private final BotConfig botConfig; + + public IncrementPointsSubcommand(QOTWPointsService pointsService, NotificationService notificationService, BotConfig botConfig) { super(pointsService, notificationService, "increment", "Adds one point to the user's QOTW-Account"); + this.botConfig = botConfig; } @Override @@ -32,8 +35,8 @@ protected int getIncrementCount(Member targetMember, SlashCommandInteractionEven } @Override - protected void sendUserNotification(@NotNull QOTWNotificationService notificationService) { - notificationService.sendAccountIncrementedNotification(); + protected void sendUserNotification(@NotNull QOTWNotificationService notificationService, Member member) { + notificationService.sendAccountIncrementedNotification(botConfig.get(member.getGuild()).getQotwConfig().getSubmissionsForumChannel()); } } diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java index 72f0d405e..2b25b0117 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java @@ -184,6 +184,9 @@ private RestAction changeForumPin(ThreadChannel toPin) { .withEmoji(emojiConfig.getFailureEmote(jda)), SelectOption.of("Decline: Empty Submission", SubmissionStatus.DECLINE_EMPTY.name()) .withDescription("The submission was empty") + .withEmoji(emojiConfig.getFailureEmote(jda)), + SelectOption.of("Decline: Plagiarism/AI content", SubmissionStatus.DECLINE_PLAGIARISM.name()) + .withDescription("The submission is plagiarized and/or AI content") .withEmoji(emojiConfig.getFailureEmote(jda)) ).build(); } diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java index 2e6d2e04c..71240c3fc 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java @@ -193,7 +193,7 @@ private boolean canCreateSubmissions(Member member) { public void acceptSubmission(@NotNull ThreadChannel thread, @NotNull User author, Member reviewedBy, boolean bestAnswer) { thread.getManager().setName(SUBMISSION_ACCEPTED + thread.getName().substring(1)).queue(); pointsService.increment(author.getIdLong()); - notificationService.withQOTW(thread.getGuild(), author).sendAccountIncrementedNotification(); + notificationService.withQOTW(thread.getGuild(), author).sendAccountIncrementedNotification(config.getSubmissionsForumChannel()); if (bestAnswer) { pointsService.increment(author.getIdLong()); notificationService.withQOTW(thread.getGuild(), author).sendBestAnswerNotification(); @@ -250,7 +250,7 @@ private enum AcceptedAnswerType{ */ public void declineSubmission(@NotNull ThreadChannel thread, User author, Member reviewedBy, SubmissionStatus status) { thread.getManager().setName(SUBMISSION_DECLINED + thread.getName().substring(1)).queue(); - notificationService.withQOTW(thread.getGuild(), author).sendSubmissionDeclinedEmbed(status); + notificationService.withQOTW(thread.getGuild(), author).sendSubmissionDeclinedEmbed(status, config.getSubmissionsForumChannel()); notificationService.withQOTW(thread.getGuild()).sendSubmissionActionNotification(reviewedBy.getUser(), new QOTWSubmission(thread), status); thread.getManager().setLocked(true).setArchived(true).queue(); } diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionStatus.java b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionStatus.java index 44ae5d9be..1997a0159 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionStatus.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionStatus.java @@ -23,7 +23,11 @@ public enum SubmissionStatus { /** * The submission got declined as it was simply empty. */ - DECLINE_EMPTY("declined (empty)"); + DECLINE_EMPTY("declined (empty)"), + /** + * The submission got declined as it was simply empty. + */ + DECLINE_PLAGIARISM("declined (plagiarism/AI)"); private final String verb; From cdf852747e7fc2f3d82d7a240e4bd25bdbde49e3 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sat, 11 Jan 2025 23:50:04 +0100 Subject: [PATCH 006/168] split message attachments in embed fields --- .../data/h2db/message_cache/MessageCache.java | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java index 13d49d4dd..ca914fe70 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java @@ -206,15 +206,24 @@ private MessageEmbed buildMessageDeleteEmbed(Guild guild, User author, MessageCh message.getMessageContent().length(), MessageEmbed.VALUE_MAX_LENGTH)), false); if (!message.getAttachments().isEmpty()) { - eb.addField("Attachments", - message - .getAttachments() - .stream() - .collect(Collectors.joining("\n")), - false); + addAttachmentsToMessageBuilder(message, eb); } - return eb - .build(); + return eb.build(); + } + + private void addAttachmentsToMessageBuilder(CachedMessage message, EmbedBuilder eb) { + StringBuilder attachmentBuilder = new StringBuilder(); + for (String attachment : message.getAttachments()) { + if(attachmentBuilder.length() + attachment.length() >= MessageEmbed.VALUE_MAX_LENGTH - 1) { + eb.addField("Attachments", attachmentBuilder.toString(),false); + attachmentBuilder.setLength(0); + + }else { + attachmentBuilder.append('\n'); + } + attachmentBuilder.append(attachment); + } + eb.addField("Attachments", attachmentBuilder.toString(), false); } private InputStream buildDeletedMessageFile(User author, CachedMessage message) { From a5343266e9aa73bde0274428383e8e866abe1757 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 13 Jan 2025 13:02:15 +0100 Subject: [PATCH 007/168] assign role for pruning --- .../systems/moderation/PruneCommand.java | 56 +++++++++++-------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/PruneCommand.java b/src/main/java/net/discordjug/javabot/systems/moderation/PruneCommand.java index c467897c6..339178257 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/PruneCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/PruneCommand.java @@ -18,14 +18,18 @@ import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; -import java.util.concurrent.TimeUnit; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Pattern; +import lombok.extern.slf4j.Slf4j; + /** *

This class represents the /prune command.

* This command will systematically ban users from the server if they match * certain criteria. */ +@Slf4j public class PruneCommand extends ModerateCommand { private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @@ -39,8 +43,6 @@ public PruneCommand(BotConfig botConfig) { .addOption(OptionType.STRING, "pattern", "A regular expression pattern to use, to remove members whose contains a match with the pattern.", false) .addOption(OptionType.STRING, "before", "Remove only users before the given timestamp. Format is yyyy-MM-dd HH:mm:ss, in UTC.", false) .addOption(OptionType.STRING, "after", "Remove only users after the given timestamp. Format is yyyy-MM-dd HH:mm:ss, in UTC.", false) - .addOption(OptionType.STRING, "reason", "The reason for issuing the prune command. This will be provided as the reason for each ban.", false) - .addOption(OptionType.INTEGER, "delete-days-of-history", "The number of days of the banned users' chat history to remove, between 0 and 7. Defaults to 0.", false) ); } @@ -54,36 +56,46 @@ protected ReplyCallbackAction handleModerationCommand(@NotNull SlashCommandInter OptionMapping patternOption = event.getOption("pattern"); OptionMapping beforeOption = event.getOption("before"); OptionMapping afterOption = event.getOption("after"); - OptionMapping reasonOption = event.getOption("reason"); - OptionMapping delDaysOption = event.getOption("delete-days-of-history"); final Pattern pattern = patternOption == null ? null : Pattern.compile(patternOption.getAsString()); final OffsetDateTime before = beforeOption == null ? null : LocalDateTime.parse(beforeOption.getAsString(), TIMESTAMP_FORMATTER).atOffset(ZoneOffset.UTC); final OffsetDateTime after = afterOption == null ? null : LocalDateTime.parse(afterOption.getAsString(), TIMESTAMP_FORMATTER).atOffset(ZoneOffset.UTC); - final int delDays = delDaysOption == null ? 0 : (int) delDaysOption.getAsLong(); - final String reason = reasonOption == null ? null : reasonOption.getAsString(); if (pattern == null && before == null && after == null) { return Responses.warning(event, "At least one filter parameter must be given; cannot remove every user from the server."); } - if (delDays < 0 || delDays > 7) { - return Responses.warning(event, "The number of days of history to delete must not be less than 0, or greater than 7."); - } - if (reason != null && reason.length() > 512) { - return Responses.warning(event, "The reason for the prune cannot be more than 512 characters."); - } - - event.getGuild().loadMembers().onSuccess(members -> { - members.forEach(member -> { - boolean shouldRemove = (pattern == null || pattern.matcher(member.getUser().getName()).find()) && - (before == null || member.getTimeJoined().isBefore(before)) && - (after == null || member.getTimeJoined().isAfter(after)); - if (shouldRemove) { - config.getLogChannel().sendMessage("Removing " + UserUtils.getUserTag(member.getUser()) + " as part of prune.").queue(); - member.ban(delDays, TimeUnit.DAYS).reason(reason).queue(); + String pruneRoleName = "prune-" + LocalDateTime.now(); + event.getGuild().createRole().setName(pruneRoleName).queue(role -> { + event.getGuild().loadMembers().onSuccess(members -> { + AtomicInteger count = new AtomicInteger(0); + members.forEach(member -> { + boolean shouldRemove = (pattern == null || pattern.matcher(member.getUser().getName()).find()) && + (before == null || member.getTimeJoined().isBefore(before)) && + (after == null || member.getTimeJoined().isAfter(after)); + if (shouldRemove) { + if(member.getUser().isBot() || !moderator.canInteract(member) || Checks.hasStaffRole(botConfig, member)) { + config.getLogChannel() + .sendMessage("# WARNING\nPrune by " + moderator.getAsMention() + " would affect " + UserUtils.getUserTag(member.getUser()) + " who is a privileged user! This is likely not intentional.") + .queue(); + return; + } + log.info("Marking " + UserUtils.getUserTag(member.getUser()) + " with " + pruneRoleName +" as part of prune."); + config.getLogChannel() + .sendMessage("Marking " + member.getAsMention() + " (" + UserUtils.getUserTag(member.getUser()) + ") with " + role.getAsMention() +" as part of prune.") + .setAllowedMentions(List.of()) + .queue(); + event.getGuild().addRoleToMember(member, role).queue(); + count.incrementAndGet(); + } + }); + int finalCount = count.get(); + config.getLogChannel().sendMessage("Prune by "+ moderator.getAsMention() +" complete - the role " + pruneRoleName + " is being assigned to " + finalCount + " members.").queue(); + if (finalCount > members.size()/10) { + config.getLogChannel().sendMessage("# WARNING\nThis prune affects a significant portion of all members!").queue(); } }); }); + return Responses.success(event, "Prune Started", "The prune action has started. Please check the log channel for information on the status of the prune."); } From 1822ff3410987463884d96f960a52416be170209 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Tue, 14 Jan 2025 22:04:39 +0100 Subject: [PATCH 008/168] fix formatting --- .../javabot/data/h2db/message_cache/MessageCache.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java index ca914fe70..606fec2fb 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java @@ -214,11 +214,11 @@ private MessageEmbed buildMessageDeleteEmbed(Guild guild, User author, MessageCh private void addAttachmentsToMessageBuilder(CachedMessage message, EmbedBuilder eb) { StringBuilder attachmentBuilder = new StringBuilder(); for (String attachment : message.getAttachments()) { - if(attachmentBuilder.length() + attachment.length() >= MessageEmbed.VALUE_MAX_LENGTH - 1) { + if (attachmentBuilder.length() + attachment.length() >= MessageEmbed.VALUE_MAX_LENGTH - 1) { eb.addField("Attachments", attachmentBuilder.toString(),false); attachmentBuilder.setLength(0); - }else { + } else { attachmentBuilder.append('\n'); } attachmentBuilder.append(attachment); From 52fa964cf8e4a198e476f66160c803a47dbd86ee Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 9 Mar 2025 13:48:58 +0100 Subject: [PATCH 009/168] update dependencies --- build.gradle.kts | 36 +++++++++---------- .../javabot/RuntimeHintsConfiguration.java | 8 +++-- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 744e7a657..d3aef4896 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,10 +3,10 @@ import com.github.jengelman.gradle.plugins.shadow.transformers.* plugins { java - id("com.github.johnrengelman.shadow") version "7.1.2" - id("org.springframework.boot") version "3.3.5" - id("io.spring.dependency-management") version "1.1.6" - id("org.graalvm.buildtools.native") version "0.10.3" + id("com.github.johnrengelman.shadow") version "8.1.1" + id("org.springframework.boot") version "3.4.3" + id("io.spring.dependency-management") version "1.1.7" + id("org.graalvm.buildtools.native") version "0.10.5" checkstyle } @@ -25,42 +25,42 @@ repositories { } dependencies { - testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.0") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.0") + testImplementation("org.junit.jupiter:junit-jupiter-api") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") compileOnly("com.google.code.findbugs:jsr305:3.0.2") - compileOnly("org.jetbrains:annotations:23.0.0") + compileOnly("org.jetbrains:annotations:26.0.2") // DIH4JDA (Command Framework) & JDA implementation("com.github.DynxstyGIT:DIH4JDA:120a15ad2e") - implementation("net.dv8tion:JDA:5.1.2") { + implementation("net.dv8tion:JDA:5.3.0") { exclude(module = "opus-java") } // Caffeine (Caching Library) - implementation("com.github.ben-manes.caffeine:caffeine:3.1.1") + implementation("com.github.ben-manes.caffeine:caffeine:3.2.0") - implementation("com.google.code.gson:gson:2.9.0") - implementation("org.yaml:snakeyaml:1.30") - implementation("com.google.re2j:re2j:1.6") - implementation("commons-validator:commons-validator:1.7") + implementation("com.google.code.gson:gson:2.12.0") + implementation("org.yaml:snakeyaml:2.4") + implementation("com.google.re2j:re2j:1.8") + implementation("commons-validator:commons-validator:1.9.0") implementation("com.mashape.unirest:unirest-java:1.4.9") // H2 Database - implementation("com.h2database:h2:2.1.212") - implementation("com.zaxxer:HikariCP:5.0.1") + implementation("com.h2database:h2:2.3.232") + implementation("com.zaxxer:HikariCP") // Webhooks implementation("com.github.DynxstyGIT:discord-webhooks:74301a46a0") // Lombok Annotations - compileOnly("org.projectlombok:lombok:1.18.30") - annotationProcessor("org.projectlombok:lombok:1.18.30") + compileOnly("org.projectlombok:lombok:1.18.36") + annotationProcessor("org.projectlombok:lombok:1.18.36") testCompileOnly("org.projectlombok:lombok:1.18.30") testAnnotationProcessor("org.projectlombok:lombok:1.18.30") // Sentry - implementation("io.sentry:sentry:6.3.0") + implementation("io.sentry:sentry:8.3.0") // Spring implementation("org.springframework.boot:spring-boot-starter-web") diff --git a/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java b/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java index 2633932e1..85e35ef3f 100644 --- a/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java +++ b/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java @@ -50,9 +50,7 @@ //ensure JDA can create necessary caches User[].class, Guild[].class, Member[].class, Role[].class, Channel[].class, AudioManager[].class, ScheduledEvent[].class, ThreadMember[].class, ForumTag[].class, RichCustomEmoji[].class, GuildSticker[].class, MemberPresenceImpl[].class, //needs to be serialized for channel managers etc - PermOverrideData.class, - //ensure that webhook embed authors can be serialized - WebhookEmbed.EmbedAuthor.class, WebhookEmbed.EmbedField.class, WebhookEmbed.EmbedFooter.class, WebhookEmbed.EmbedTitle.class + PermOverrideData.class }) public class RuntimeHintsConfiguration implements RuntimeHintsRegistrar { @@ -74,5 +72,9 @@ public void registerHints(RuntimeHints hints, ClassLoader classLoader) { // caffeine hints.reflection().registerTypeIfPresent(getClass().getClassLoader(), "com.github.benmanes.caffeine.cache.SSW", MemberCategory.INVOKE_DECLARED_METHODS, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS); + + for (Class cl : WebhookEmbed.class.getClasses()) { + hints.reflection().registerType(cl, MemberCategory.DECLARED_FIELDS, MemberCategory.INVOKE_PUBLIC_METHODS); + } } } From 8df3ce12b80940aeaafd74bb09be57d2cf80902d Mon Sep 17 00:00:00 2001 From: danthe1st Date: Tue, 25 Mar 2025 22:52:04 +0100 Subject: [PATCH 010/168] set uninvitable after adding member --- .../javabot/systems/moderation/report/ReportManager.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java b/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java index f8e739a86..502229e33 100644 --- a/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java +++ b/src/main/java/net/discordjug/javabot/systems/moderation/report/ReportManager.java @@ -87,7 +87,8 @@ private void createReportUserThread(ButtonInteractionEvent event, String[] id) { + "Messages sent in this thread can be seen by staff members but not other users.") .addEmbeds(reportEmbeds) .queue(); - reporterThread.addThreadMember(event.getUser()).queue(); + reporterThread.addThreadMember(event.getUser()).queue(success -> + reporterThread.getManager().setInvitable(false).queue()); reportThread .sendMessageEmbeds( new EmbedBuilder() From b9cab5a79a9fcfa2447f3a34d734aa7412f2b800 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Tue, 25 Mar 2025 23:11:52 +0100 Subject: [PATCH 011/168] add rate limit to staff activity monitor --- .../staff_activity/StaffActivityService.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_activity/StaffActivityService.java b/src/main/java/net/discordjug/javabot/systems/staff_activity/StaffActivityService.java index 7bd925228..84652d5ad 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_activity/StaffActivityService.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_activity/StaffActivityService.java @@ -1,8 +1,13 @@ package net.discordjug.javabot.systems.staff_activity; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.time.temporal.TemporalAccessor; import java.util.Iterator; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import org.springframework.stereotype.Service; @@ -29,6 +34,7 @@ public class StaffActivityService { private final BotConfig botConfig; private final StaffActivityMessageRepository repository; + private final Map lastActivities = new ConcurrentHashMap<>(); /** * Updates the staff activity message or creates it if necessary. @@ -43,6 +49,17 @@ public void updateStaffActivity(StaffActivityType type, TemporalAccessor timesta if (staffActivityChannel == null) { return; } + Instant now = Instant.now(); + Instant merged = lastActivities.merge(new StaffActivityKey(member.getGuild().getIdLong(), member.getIdLong(), type), now, (oldValue, currentInstant) -> { + if (timeDifferenceIsBelowRateLimit(oldValue, currentInstant)) { + // less than 5 minutes since insertion + return oldValue; + } + return currentInstant; + }); + if (!merged.equals(now)) { + return; + } Long msgId = repository.getMessageId(staffActivityChannel.getGuild().getIdLong(), member.getIdLong()); if (msgId != null) { staffActivityChannel @@ -53,6 +70,12 @@ public void updateStaffActivity(StaffActivityType type, TemporalAccessor timesta } else { createNewMessage(staffActivityChannel, member, type, timestamp); } + lastActivities.entrySet() + .removeIf(e -> !timeDifferenceIsBelowRateLimit(e.getValue(),now)); + } + + private boolean timeDifferenceIsBelowRateLimit(Instant oldValue, Instant currentInstant) { + return Duration.between(oldValue, currentInstant).minus(Duration.of(5, ChronoUnit.MINUTES)).isNegative(); } private void replaceActivityMessage(StaffActivityType type, TemporalAccessor timestamp, Message activityMessage, Member member) { @@ -96,4 +119,6 @@ private MessageEmbed createEmptyStaffActivityEmbed(Member member) { .setFooter(member.getId()) .build(); } + + private record StaffActivityKey(long guildId, long memberId, StaffActivityType type) {} } From 94b36d606b86ec2ef28fcfbbbac81d12e0e95b56 Mon Sep 17 00:00:00 2001 From: Defective Date: Sun, 13 Apr 2025 22:07:50 +0200 Subject: [PATCH 012/168] Fix permission settings for QOTW submissions channel in QOTW jobs --- .../javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java | 7 ++++++- .../net/discordjug/javabot/systems/qotw/jobs/QOTWJob.java | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java index 2b25b0117..8cf0141c6 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWCloseSubmissionsJob.java @@ -46,6 +46,7 @@ import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; @@ -76,7 +77,11 @@ public void execute() throws SQLException { GuildConfig config = botConfig.get(guild); QOTWConfig qotwConfig = config.getQotwConfig(); qotwConfig.getSubmissionChannel().getManager() - .putRolePermissionOverride(guild.getIdLong(), Collections.emptySet(), Collections.singleton(Permission.MESSAGE_SEND_IN_THREADS)) + .putRolePermissionOverride(guild.getIdLong(), Collections.emptySet(), Set.of(Permission.MESSAGE_SEND_IN_THREADS, + Permission.MESSAGE_SEND, + Permission.CREATE_PRIVATE_THREADS, + Permission.CREATE_PUBLIC_THREADS, + Permission.MESSAGE_ADD_REACTION)) .queue(); TextChannel logChannel = config.getModerationConfig().getLogChannel(); if (logChannel == null) continue; diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWJob.java b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWJob.java index ec18d69ca..83fc237a6 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWJob.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/jobs/QOTWJob.java @@ -22,7 +22,6 @@ import java.sql.SQLException; import java.time.OffsetDateTime; -import java.util.Collections; import java.util.Optional; import java.util.Set; @@ -59,7 +58,11 @@ public void execute() throws SQLException { thread.getManager().setLocked(true).setArchived(true).queue(); }); qotw.getSubmissionChannel().getManager() - .putRolePermissionOverride(guild.getIdLong(), Set.of(Permission.VIEW_CHANNEL, Permission.MESSAGE_SEND_IN_THREADS), Collections.singleton(Permission.MESSAGE_SEND)) + .putRolePermissionOverride(guild.getIdLong(), Set.of(Permission.VIEW_CHANNEL, Permission.MESSAGE_SEND_IN_THREADS), Set.of( + Permission.MESSAGE_SEND, + Permission.CREATE_PRIVATE_THREADS, + Permission.CREATE_PUBLIC_THREADS, + Permission.MESSAGE_ADD_REACTION)) .queue(); if (question.getQuestionNumber() == null) { question.setQuestionNumber(questionQueueRepository.getNextQuestionNumber()); From 9441ed681787912e5a21a0b364f3206b54ac74b1 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 25 May 2025 12:58:51 +0200 Subject: [PATCH 013/168] Add thread link if submission thread exists already --- .../qotw/submissions/SubmissionManager.java | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java index 71240c3fc..976288394 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java @@ -69,8 +69,9 @@ public class SubmissionManager { public WebhookMessageCreateAction handleSubmission(@NotNull ButtonInteractionEvent event, int questionNumber) { event.deferEdit().queue(); Member member = event.getMember(); - if (!canCreateSubmissions(member)) { - return Responses.warning(event.getHook(), "You're not eligible to create a new submission thread."); + CreateSubmissionResult createSubmissionCheckResult = canCreateSubmissions(member); + if (!createSubmissionCheckResult.canCreateSubmissions()) { + return Responses.warning(event.getHook(), "You're not eligible to create a new submission thread because " + createSubmissionCheckResult.errorMessage()); } config.getSubmissionChannel().createThreadChannel( String.format(THREAD_NAME, questionNumber, member.getId()), true).queue( @@ -174,13 +175,21 @@ public void handleThreadDeletion(@NotNull ButtonInteractionEvent event) { }); } - private boolean canCreateSubmissions(Member member) { - if (member == null) return false; - if (member.getUser().isBot() || member.getUser().isSystem()) return false; - if (member.isTimedOut() || member.isPending()) return false; - return config.getSubmissionChannel().getThreadChannels() - .stream().noneMatch(p -> p.getName().contains(member.getId())); + private CreateSubmissionResult canCreateSubmissions(Member member) { + if (member == null) return new CreateSubmissionResult(false, "your user cannot be found in this server - please try again later or contact staff members."); + if (member.getUser().isBot() || member.getUser().isSystem()) return new CreateSubmissionResult(false, "your account seems to be marked as a bot."); + if (member.isTimedOut() || member.isPending()) return new CreateSubmissionResult(false, "you are timed out or have not accepted the rules"); + + Optional existingSubmissionThread = config.getSubmissionChannel().getThreadChannels() + .stream().filter(p -> p.getName().contains(member.getId())).findAny(); + + return existingSubmissionThread + .map(thread -> new CreateSubmissionResult(false, + "you already have a submission thread: " + existingSubmissionThread.get().getJumpUrl())) + .orElse(new CreateSubmissionResult(true, "")); } + + record CreateSubmissionResult(boolean canCreateSubmissions, String errorMessage) {} /** * Accepts a submission. From bbbe72468e525ed12e48a3db2343c89602865f65 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 25 May 2025 14:04:50 +0200 Subject: [PATCH 014/168] move nested record to bottom --- .../javabot/systems/qotw/submissions/SubmissionManager.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java index 976288394..003a52f91 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java @@ -189,7 +189,6 @@ private CreateSubmissionResult canCreateSubmissions(Member member) { .orElse(new CreateSubmissionResult(true, "")); } - record CreateSubmissionResult(boolean canCreateSubmissions, String errorMessage) {} /** * Accepts a submission. @@ -301,4 +300,6 @@ private CompletableFuture> getMessagesByUser(@NotNull ThreadChanne .setTimestamp(Instant.now()) .build(); } + + private record CreateSubmissionResult(boolean canCreateSubmissions, String errorMessage) {} } From f6490f840f07ade092ff501d7181b529c618ddce Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 25 May 2025 14:06:43 +0200 Subject: [PATCH 015/168] change wording --- .../javabot/systems/qotw/submissions/SubmissionManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java index 003a52f91..6f6949888 100644 --- a/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/qotw/submissions/SubmissionManager.java @@ -185,7 +185,7 @@ private CreateSubmissionResult canCreateSubmissions(Member member) { return existingSubmissionThread .map(thread -> new CreateSubmissionResult(false, - "you already have a submission thread: " + existingSubmissionThread.get().getJumpUrl())) + "you already have one: " + existingSubmissionThread.get().getJumpUrl())) .orElse(new CreateSubmissionResult(true, "")); } From 2dccc022305f275d897a6dde2f24b22a6cc4c4cf Mon Sep 17 00:00:00 2001 From: danthe1st Date: Thu, 29 May 2025 15:58:57 +0200 Subject: [PATCH 016/168] update dependencies --- build.gradle.kts | 18 ++++++------ gradle/wrapper/gradle-wrapper.jar | Bin 43462 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 12 ++++---- gradlew.bat | 26 ++++++++++-------- .../javabot/RuntimeHintsConfiguration.java | 8 +++++- 6 files changed, 38 insertions(+), 28 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index d3aef4896..7d2633705 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,9 +4,9 @@ import com.github.jengelman.gradle.plugins.shadow.transformers.* plugins { java id("com.github.johnrengelman.shadow") version "8.1.1" - id("org.springframework.boot") version "3.4.3" + id("org.springframework.boot") version "3.5.0" id("io.spring.dependency-management") version "1.1.7" - id("org.graalvm.buildtools.native") version "0.10.5" + id("org.graalvm.buildtools.native") version "0.10.6" checkstyle } @@ -32,14 +32,14 @@ dependencies { // DIH4JDA (Command Framework) & JDA implementation("com.github.DynxstyGIT:DIH4JDA:120a15ad2e") - implementation("net.dv8tion:JDA:5.3.0") { + implementation("net.dv8tion:JDA:5.5.1") { exclude(module = "opus-java") } // Caffeine (Caching Library) implementation("com.github.ben-manes.caffeine:caffeine:3.2.0") - implementation("com.google.code.gson:gson:2.12.0") + implementation("com.google.code.gson:gson:2.13.1") implementation("org.yaml:snakeyaml:2.4") implementation("com.google.re2j:re2j:1.8") implementation("commons-validator:commons-validator:1.9.0") @@ -54,13 +54,13 @@ dependencies { implementation("com.github.DynxstyGIT:discord-webhooks:74301a46a0") // Lombok Annotations - compileOnly("org.projectlombok:lombok:1.18.36") - annotationProcessor("org.projectlombok:lombok:1.18.36") - testCompileOnly("org.projectlombok:lombok:1.18.30") - testAnnotationProcessor("org.projectlombok:lombok:1.18.30") + compileOnly("org.projectlombok:lombok:1.18.38") + annotationProcessor("org.projectlombok:lombok:1.18.38") + testCompileOnly("org.projectlombok:lombok:1.18.38") + testAnnotationProcessor("org.projectlombok:lombok:1.18.38") // Sentry - implementation("io.sentry:sentry:8.3.0") + implementation("io.sentry:sentry:8.13.2") // Spring implementation("org.springframework.boot:spring-boot-starter-web") diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index d64cd4917707c1f8861d8cb53dd15194d4248596..1b33c55baabb587c669f562ae36f953de2481846 100644 GIT binary patch delta 34753 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%0AF=STgWRWL+V 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 W(m*lz*HoBlEiM*bU{am~Dx&}eeNGnu diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1af9e0930..002b867c4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 1aa94a426..23d15a936 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -84,7 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -112,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -203,7 +205,7 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. @@ -211,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 93e3f59f1..db3a6ac20 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -43,11 +45,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -57,22 +59,22 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java b/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java index 85e35ef3f..d2b30f876 100644 --- a/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java +++ b/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java @@ -3,6 +3,7 @@ import java.nio.channels.Channel; import club.minnced.discord.webhook.send.WebhookEmbed; +import com.zaxxer.hikari.HikariConfig; import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.config.GuildConfig; import net.discordjug.javabot.data.config.GuildConfigItem; @@ -26,6 +27,7 @@ import net.dv8tion.jda.api.entities.sticker.GuildSticker; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.managers.AudioManager; +import net.dv8tion.jda.internal.entities.GuildVoiceStateImpl; import net.dv8tion.jda.internal.entities.MemberPresenceImpl; import net.dv8tion.jda.internal.requests.restaction.PermOverrideData; import org.h2.server.TcpServer; @@ -50,7 +52,9 @@ //ensure JDA can create necessary caches User[].class, Guild[].class, Member[].class, Role[].class, Channel[].class, AudioManager[].class, ScheduledEvent[].class, ThreadMember[].class, ForumTag[].class, RichCustomEmoji[].class, GuildSticker[].class, MemberPresenceImpl[].class, //needs to be serialized for channel managers etc - PermOverrideData.class + PermOverrideData.class, + + HikariConfig.class }) public class RuntimeHintsConfiguration implements RuntimeHintsRegistrar { @@ -76,5 +80,7 @@ public void registerHints(RuntimeHints hints, ClassLoader classLoader) { for (Class cl : WebhookEmbed.class.getClasses()) { hints.reflection().registerType(cl, MemberCategory.DECLARED_FIELDS, MemberCategory.INVOKE_PUBLIC_METHODS); } + + hints.reflection().registerType(GuildVoiceStateImpl[].class, MemberCategory.UNSAFE_ALLOCATED); } } From 278c5b1975fe93dd577bea8dcba75868c9fe1d38 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 15 Jun 2025 10:34:47 +0200 Subject: [PATCH 017/168] fix error response of /format-code --- .../systems/user_commands/format_code/FormatCodeCommand.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeCommand.java b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeCommand.java index 191d2e3cb..68591d470 100644 --- a/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/user_commands/format_code/FormatCodeCommand.java @@ -86,7 +86,7 @@ public void execute(@NotNull SlashCommandInteractionEvent event) { }); } else { if (Checks.isInvalidLongInput(idOption)) { - Responses.error(event, "Please provide a valid message id!").queue(); + Responses.error(event.getHook(), "Please provide a valid message id!").queue(); return; } long messageId = idOption.getAsLong(); From 39f3f7f643d1bc55f8db8f8665681cd27f073592 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 15 Jun 2025 10:44:02 +0200 Subject: [PATCH 018/168] reduce log level of common logs --- .../net/discordjug/javabot/systems/help/HelpForumUpdater.java | 2 +- .../javabot/systems/help/dao/HelpTransactionRepository.java | 2 +- src/main/java/net/discordjug/javabot/tasks/MetricsUpdater.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/help/HelpForumUpdater.java b/src/main/java/net/discordjug/javabot/systems/help/HelpForumUpdater.java index 4d134a081..eef9d7ef2 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/HelpForumUpdater.java +++ b/src/main/java/net/discordjug/javabot/systems/help/HelpForumUpdater.java @@ -38,7 +38,7 @@ public class HelpForumUpdater { @Scheduled(cron = "0 */10 * * * *") // Run every 10 minutes public void execute() { for (Guild guild : jda.getGuilds()) { - log.info("Checking for inactive forum posts in {}", guild.getName()); + log.debug("Checking for inactive forum posts in {}", guild.getName()); HelpConfig config = botConfig.get(guild).getHelpConfig(); ForumChannel forum = config.getHelpForumChannel(); if (forum != null) { diff --git a/src/main/java/net/discordjug/javabot/systems/help/dao/HelpTransactionRepository.java b/src/main/java/net/discordjug/javabot/systems/help/dao/HelpTransactionRepository.java index b7210d093..2446fc72f 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/dao/HelpTransactionRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/help/dao/HelpTransactionRepository.java @@ -47,7 +47,7 @@ public HelpTransaction save(HelpTransaction transaction) throws DataAccessExcept "channel",transaction.getChannelId()) ); transaction.setId(key.longValue()); - log.info("Inserted new Help Transaction: {}", transaction); + log.debug("Inserted new Help Transaction: {}", transaction); return transaction; } diff --git a/src/main/java/net/discordjug/javabot/tasks/MetricsUpdater.java b/src/main/java/net/discordjug/javabot/tasks/MetricsUpdater.java index 55ae2c678..2769ac504 100644 --- a/src/main/java/net/discordjug/javabot/tasks/MetricsUpdater.java +++ b/src/main/java/net/discordjug/javabot/tasks/MetricsUpdater.java @@ -48,7 +48,7 @@ public void onReady(@NotNull ReadyEvent event) { for (Map.Entry> entry : TEXT_VARIABLES.entrySet()) { text = text.replace(entry.getKey(), entry.getValue().apply(guild)); } - config.getMetricsCategory().getManager().setName(text).queue(s -> log.info("Successfully updated Metrics"), t -> ExceptionLogger.capture(t, getClass().getSimpleName())); + config.getMetricsCategory().getManager().setName(text).queue(s -> log.debug("Successfully updated Metrics"), t -> ExceptionLogger.capture(t, getClass().getSimpleName())); } }, 0, 20, TimeUnit.MINUTES); } From 608db8f84c32711b8cbea3ac1b3a079e9678fcb1 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 15 Jun 2025 10:50:35 +0200 Subject: [PATCH 019/168] ignore closed DMs in PingableNameListener --- .../discordjug/javabot/listener/PingableNameListener.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/listener/PingableNameListener.java b/src/main/java/net/discordjug/javabot/listener/PingableNameListener.java index 6483995ff..a4e4671fd 100644 --- a/src/main/java/net/discordjug/javabot/listener/PingableNameListener.java +++ b/src/main/java/net/discordjug/javabot/listener/PingableNameListener.java @@ -73,8 +73,10 @@ private void changeName(Member member) { String newName = generateRandomName(); member.modifyNickname(newName.substring(0, Math.min(31, newName.length()))).queue(); member.getUser().openPrivateChannel() - .flatMap(channel -> channel.sendMessageFormat("Your display name in %s has been set to `%s` since your previous name was deemed as non-pingable.", member.getGuild().getName(), newName)) - .queue(); + .flatMap(channel -> + channel.sendMessageFormat("Your display name in %s has been set to `%s` since your previous name was deemed as non-pingable.", + member.getGuild().getName(), newName)) + .queue(success -> {}, err -> {});//ignore closed DMs notificationService.withGuild(member.getGuild()).sendToMessageLog(c -> c.sendMessageFormat("Changed %s's nickname from `%s` to `%s`.", member.getAsMention(), oldName, newName)); } From ad9776217b08dfaa93b7bd28c35af3625d0a7342 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Sun, 15 Jun 2025 11:22:10 +0200 Subject: [PATCH 020/168] update DIH4JDA --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 7d2633705..10b6c78d3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,7 +31,7 @@ dependencies { compileOnly("org.jetbrains:annotations:26.0.2") // DIH4JDA (Command Framework) & JDA - implementation("com.github.DynxstyGIT:DIH4JDA:120a15ad2e") + implementation("com.github.DynxstyGIT:DIH4JDA:a64b5a9dc5") implementation("net.dv8tion:JDA:5.5.1") { exclude(module = "opus-java") } From 5ab796247c603bf93456e2c6d1e91e604e5092f2 Mon Sep 17 00:00:00 2001 From: ayy <180436638+ayyhimself@users.noreply.github.com> Date: Thu, 21 Aug 2025 20:42:50 +0200 Subject: [PATCH 021/168] add message filter handler and blacklisted attachment filter --- .../javabot/data/config/GuildConfig.java | 3 + .../javabot/listener/HugListener.java | 120 ------------------ .../BlacklistedMessageAttachmentFilter.java | 41 ++++++ .../javabot/listener/filter/HugFilter.java | 74 +++++++++++ .../listener/filter/MessageContent.java | 20 +++ .../listener/filter/MessageFilter.java | 23 ++++ .../listener/filter/MessageFilterHandler.java | 118 +++++++++++++++++ .../filter/MessageModificationStatus.java | 24 ++++ .../discordjug/javabot/util/WebhookUtil.java | 44 +++++-- 9 files changed, 337 insertions(+), 130 deletions(-) delete mode 100644 src/main/java/net/discordjug/javabot/listener/HugListener.java create mode 100644 src/main/java/net/discordjug/javabot/listener/filter/BlacklistedMessageAttachmentFilter.java create mode 100644 src/main/java/net/discordjug/javabot/listener/filter/HugFilter.java create mode 100644 src/main/java/net/discordjug/javabot/listener/filter/MessageContent.java create mode 100644 src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java create mode 100644 src/main/java/net/discordjug/javabot/listener/filter/MessageFilterHandler.java create mode 100644 src/main/java/net/discordjug/javabot/listener/filter/MessageModificationStatus.java diff --git a/src/main/java/net/discordjug/javabot/data/config/GuildConfig.java b/src/main/java/net/discordjug/javabot/data/config/GuildConfig.java index 6e57bfe0e..66eb7213d 100644 --- a/src/main/java/net/discordjug/javabot/data/config/GuildConfig.java +++ b/src/main/java/net/discordjug/javabot/data/config/GuildConfig.java @@ -18,6 +18,7 @@ import java.lang.reflect.Field; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import java.util.Optional; /** @@ -37,6 +38,7 @@ public class GuildConfig { private StarboardConfig starboardConfig; private MessageCacheConfig messageCacheConfig; private ServerLockConfig serverLockConfig; + private List blacklistedMessageExtensions; /** * Constructor that initializes all Config classes. @@ -54,6 +56,7 @@ public GuildConfig(Guild guild, Path file) { this.starboardConfig = new StarboardConfig(); this.messageCacheConfig = new MessageCacheConfig(); this.serverLockConfig = new ServerLockConfig(); + this.blacklistedMessageExtensions = List.of("jar", "exe", "zip"); this.setGuild(guild); } diff --git a/src/main/java/net/discordjug/javabot/listener/HugListener.java b/src/main/java/net/discordjug/javabot/listener/HugListener.java deleted file mode 100644 index 46eac3ea9..000000000 --- a/src/main/java/net/discordjug/javabot/listener/HugListener.java +++ /dev/null @@ -1,120 +0,0 @@ -package net.discordjug.javabot.listener; - -import lombok.RequiredArgsConstructor; -import net.discordjug.javabot.data.config.BotConfig; -import net.discordjug.javabot.systems.moderation.AutoMod; -import net.discordjug.javabot.util.ExceptionLogger; -import net.discordjug.javabot.util.WebhookUtil; -import net.dv8tion.jda.api.entities.channel.ChannelType; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; -import net.dv8tion.jda.api.entities.channel.middleman.StandardGuildChannel; -import net.dv8tion.jda.api.events.message.MessageReceivedEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; - -import javax.annotation.Nonnull; -import java.util.Objects; -import java.util.regex.Pattern; - -/** - * Replaces all occurrences of 'fuck' in incoming messages with 'hug'. - */ -@RequiredArgsConstructor -public class HugListener extends ListenerAdapter { - private static final Pattern FUCKER = Pattern.compile( - "(fuck)(ing|er|ed|k+)?", - Pattern.CASE_INSENSITIVE - ); - private final AutoMod autoMod; - private final BotConfig botConfig; - - private static String processHug(String originalText) { - // FucK -> HuG, FuCk -> Hug - return String.valueOf(copyCase(originalText, 0, 'h')) + copyCase(originalText, 1, 'u') + - copyCase(originalText, 3, 'g'); - } - - private static String replaceFucks(String str) { - return FUCKER.matcher(str).replaceAll(matchResult -> { - String theFuck = matchResult.group(1); - String suffix = Objects.requireNonNullElse(matchResult.group(2), ""); - String processedSuffix = switch (suffix.toLowerCase()) { - case "er", "ed", "ing" -> - copyCase(suffix, 0, 'g') + suffix; // fucking, fucker, fucked - case "" -> ""; // just fuck - default -> copyCase(suffix, "g".repeat(suffix.length())); // fuckkkkk... - }; - return processHug(theFuck) + processedSuffix; - }); - } - - private static String copyCase(String source, String toChange) { - if (source.length() != toChange.length()) { - throw new IllegalArgumentException("lengths differ"); - } - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < source.length(); i++) { - sb.append(copyCase(source, i, toChange.charAt(i))); - } - return sb.toString(); - } - - private static char copyCase(String original, int index, char newChar) { - if (Character.isUpperCase(original.charAt(index))) { - return Character.toUpperCase(newChar); - } else { - return newChar; - } - } - - @Override - public void onMessageReceived(@Nonnull MessageReceivedEvent event) { - if (!event.isFromGuild()) { - return; - } - if (autoMod.hasSuspiciousLink(event.getMessage()) || - autoMod.hasAdvertisingLink(event.getMessage())) { - return; - } - if (!event.getMessage().getMentions().getUsers().isEmpty()) { - return; - } - if (event.isWebhookMessage()) { - return; - } - if (event.getChannel().getIdLong() == botConfig.get(event.getGuild()) - .getModerationConfig() - .getSuggestionChannelId()) { - return; - } - TextChannel tc = null; - if (event.isFromType(ChannelType.TEXT)) { - tc = event.getChannel().asTextChannel(); - } - if (event.isFromThread()) { - StandardGuildChannel parentChannel = event.getChannel() - .asThreadChannel() - .getParentChannel() - .asStandardGuildChannel(); - if (parentChannel instanceof TextChannel textChannel) { - tc = textChannel; - } - } - if (tc == null) { - return; - - } - String content = event.getMessage().getContentRaw(); - if (FUCKER.matcher(content).find()) { - long threadId = event.isFromThread() ? event.getChannel().getIdLong() : 0; - WebhookUtil.ensureWebhookExists( - tc, - wh -> WebhookUtil.replaceMemberMessage(wh, event.getMessage(), - replaceFucks(content), threadId - ), - e -> ExceptionLogger.capture(e, getClass().getSimpleName()) - ); - } - } - - -} diff --git a/src/main/java/net/discordjug/javabot/listener/filter/BlacklistedMessageAttachmentFilter.java b/src/main/java/net/discordjug/javabot/listener/filter/BlacklistedMessageAttachmentFilter.java new file mode 100644 index 000000000..fb80ef294 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/BlacklistedMessageAttachmentFilter.java @@ -0,0 +1,41 @@ +package net.discordjug.javabot.listener.filter; + +import lombok.RequiredArgsConstructor; +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.data.config.GuildConfig; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * This {@link MessageFilter} blocks attachments blacklisted using {@link GuildConfig#getBlacklistedMessageExtensions()}. + */ +@Component +@RequiredArgsConstructor +public class BlacklistedMessageAttachmentFilter implements MessageFilter { + + private final BotConfig botConfig; + + @Override + public MessageModificationStatus processMessage(MessageContent content) { + MessageReceivedEvent event = content.event(); + List attachments = content.attachments(); + List embeds = content.embeds(); + GuildConfig guildConfig = botConfig.get(event.getGuild()); + List blacklistedMessageExtensions = guildConfig.getBlacklistedMessageExtensions(); + boolean removed = attachments.removeIf(attachment -> blacklistedMessageExtensions.contains(attachment.getFileExtension())); + if (removed) { + MessageEmbed attachmentRemovedInfo = new EmbedBuilder() + .setDescription("Disallowed attachments have been removed from this message.") + .build(); + embeds.add(attachmentRemovedInfo); + return MessageModificationStatus.MODIFIED; + } else { + return MessageModificationStatus.NOT_MODIFIED; + } + } +} diff --git a/src/main/java/net/discordjug/javabot/listener/filter/HugFilter.java b/src/main/java/net/discordjug/javabot/listener/filter/HugFilter.java new file mode 100644 index 000000000..63785dc83 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/HugFilter.java @@ -0,0 +1,74 @@ +package net.discordjug.javabot.listener.filter; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * This {@link MessageFilter} replaces all occurrences of 'fuck' in incoming messages with 'hug'. + */ +@Component +@RequiredArgsConstructor +public class HugFilter implements MessageFilter { + + private static final Pattern FUCKER = Pattern.compile( + "(fuck)(ing|er|ed|k+)?", + Pattern.CASE_INSENSITIVE + ); + + @Override + public MessageModificationStatus processMessage(MessageContent content) { + if (!content.event().getMessage().getMentions().getUsers().isEmpty()) { + return MessageModificationStatus.NOT_MODIFIED; + } + String before = content.messageText().toString(); + String processed = replaceFucks(content.messageText().toString()); + if (!before.equals(processed)) { + content.messageText().setLength(0); + content.messageText().append(processed); + return MessageModificationStatus.MODIFIED; + } else { + return MessageModificationStatus.NOT_MODIFIED; + } + } + + private static String processHug(String originalText) { + // FucK -> HuG, FuCk -> Hug + return String.valueOf(copyCase(originalText, 0, 'h')) + copyCase(originalText, 1, 'u') + + copyCase(originalText, 3, 'g'); + } + + private static String replaceFucks(String str) { + return FUCKER.matcher(str).replaceAll(matchResult -> { + String theFuck = matchResult.group(1); + String suffix = Objects.requireNonNullElse(matchResult.group(2), ""); + String processedSuffix = switch (suffix.toLowerCase()) { + case "er", "ed", "ing" -> copyCase(suffix, 0, 'g') + suffix; // fucking, fucker, fucked + case "" -> ""; // just fuck + default -> copyCase(suffix, "g".repeat(suffix.length())); // fuckkkkk... + }; + return processHug(theFuck) + processedSuffix; + }); + } + + private static String copyCase(String source, String toChange) { + if (source.length() != toChange.length()) { + throw new IllegalArgumentException("lengths differ"); + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < source.length(); i++) { + sb.append(copyCase(source, i, toChange.charAt(i))); + } + return sb.toString(); + } + + private static char copyCase(String original, int index, char newChar) { + if (Character.isUpperCase(original.charAt(index))) { + return Character.toUpperCase(newChar); + } else { + return newChar; + } + } +} diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageContent.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageContent.java new file mode 100644 index 000000000..53bd21003 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageContent.java @@ -0,0 +1,20 @@ +package net.discordjug.javabot.listener.filter; + +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; + +import java.util.List; + +/** + * A class storing different parameters of a message sent. + * @param event The event associated with the message + * @param messageText The text associated with the message + * @param attachments The attachments associated with the message + * @param embeds The embeds associated with the message + */ +public record MessageContent(MessageReceivedEvent event, + StringBuilder messageText, + List attachments, + List embeds) { +} diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java new file mode 100644 index 000000000..dd56679a1 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java @@ -0,0 +1,23 @@ +package net.discordjug.javabot.listener.filter; + +/** + * This interface is implemented by all message filters. + * + * The {@link MessageContent} is processed through every class implementing {@link MessageFilter} + * unless one of the filters returns {@link MessageModificationStatus#STOP_PROCESSING} which stops further filters from processing. + */ +public interface MessageFilter { + + /** + * When a message is received, it is executed on the registered filters. + * + * @param content The content of the new message that will be reposted instead of the received message + * if at least one filter returns {@link MessageModificationStatus#MODIFIED} + * and no filter returns {@link MessageModificationStatus#STOP_PROCESSING}. + * This {@link MessageContent} is built up incrementally by the filters. + * @return the appropriate {@link MessageModificationStatus} based on the filter's processing. + * @see MessageFilterHandler for the implementation of the filters. + */ + MessageModificationStatus processMessage(MessageContent content); + +} diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageFilterHandler.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilterHandler.java new file mode 100644 index 000000000..0700b21d4 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilterHandler.java @@ -0,0 +1,118 @@ +package net.discordjug.javabot.listener.filter; + +import lombok.RequiredArgsConstructor; +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.moderation.AutoMod; +import net.discordjug.javabot.util.ExceptionLogger; +import net.discordjug.javabot.util.WebhookUtil; +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.attribute.IWebhookContainer; +import net.dv8tion.jda.api.entities.channel.middleman.StandardGuildChannel; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; + +/** + * This class is responsible for calling {@link MessageFilter}s on incoming messages and optionally replacing the message. + * + * When a message is received, registered {@link MessageFilter filters} are executed sequentially to process the message. + * These filters are able to act on the message and modify message contents using the {@link MessageContent} record. + * Modifications to the message are performed after all filters are executed by deleting the original message and re-sending a modified message. + * + * Message filters are Spring components implementing the {@link MessageFilter} interface. + */ +@RequiredArgsConstructor +public class MessageFilterHandler extends ListenerAdapter { + + private final List filters; + private final AutoMod autoMod; + private final BotConfig botConfig; + + @Override + public void onMessageReceived(@NotNull MessageReceivedEvent event) { + if (!shouldRunFilters(event)) { + return; + } + + MessageContent content = new MessageContent( + event, + new StringBuilder(event.getMessage().getContentRaw()), + new ArrayList<>(event.getMessage().getAttachments()), + new ArrayList<>(event.getMessage().getEmbeds()) + ); + + boolean handled = false; + + for (MessageFilter filter : filters) { + MessageModificationStatus status = filter.processMessage(content); + if (status == MessageModificationStatus.MODIFIED) { + handled = true; + } else if (status == MessageModificationStatus.STOP_PROCESSING) { + return; + } + } + + if (handled) { + IWebhookContainer webhookContainer = null; + long threadId = 0; + if (event.isFromType(ChannelType.TEXT)) { + webhookContainer = event.getChannel().asTextChannel(); + } + if (event.isFromThread()) { + StandardGuildChannel parentChannel = event.getChannel() + .asThreadChannel() + .getParentChannel() + .asStandardGuildChannel(); + threadId = event.getChannel().getIdLong(); + webhookContainer = (IWebhookContainer) parentChannel; + } + if (webhookContainer == null) { + return; + } + replaceMessage(webhookContainer, threadId, content); + } + } + + private boolean shouldRunFilters(@NotNull MessageReceivedEvent event) { + if (event.isWebhookMessage()) { + return false; + } + if (!event.isFromGuild()) { + return false; + } + if (event.getAuthor().isBot() || event.getAuthor().isSystem()) { + return false; + } + if (autoMod.hasSuspiciousLink(event.getMessage()) || + autoMod.hasAdvertisingLink(event.getMessage())) { + return false; + } + if (event.getChannel().getIdLong() == botConfig.get(event.getGuild()) + .getModerationConfig() + .getSuggestionChannelId()) { + return false; + } + return true; + } + + private void replaceMessage(IWebhookContainer webhookContainer, long threadId, MessageContent content) { + WebhookUtil.ensureWebhookExists( + webhookContainer, + wh -> WebhookUtil.replaceMemberMessage( + wh, + content.event().getMessage(), + content.messageText().toString(), + threadId, + content.attachments(), + content.embeds() + ), + e -> ExceptionLogger.capture( + e, + this.getClass().getSimpleName() + ) + ); + } +} diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageModificationStatus.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageModificationStatus.java new file mode 100644 index 000000000..ef58f8775 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageModificationStatus.java @@ -0,0 +1,24 @@ +package net.discordjug.javabot.listener.filter; + +/** + * This enum describes the result of an {@link MessageFilter} execution and what should be done with the message. + */ +public enum MessageModificationStatus { + /** + * The message representation has been modified requiring the message to be deleted and re-sent. + * + * Further filters will be executed. + */ + MODIFIED, + /** + * The message representation has not been modified and the filter does not require the message to be deleted and re-sent. + * + * If another filter returns {@link #MODIFIED}, the message is still deleted and re-sent. + * Further filters will be executed. + */ + NOT_MODIFIED, + /** + * Indicates that no further filters should be executed on the message and that the message should not be deleted and re-sent by the filter handling logic. + */ + STOP_PROCESSING +} \ No newline at end of file diff --git a/src/main/java/net/discordjug/javabot/util/WebhookUtil.java b/src/main/java/net/discordjug/javabot/util/WebhookUtil.java index 485caf41c..f7c40cf15 100644 --- a/src/main/java/net/discordjug/javabot/util/WebhookUtil.java +++ b/src/main/java/net/discordjug/javabot/util/WebhookUtil.java @@ -17,6 +17,7 @@ import org.jetbrains.annotations.Nullable; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; @@ -91,17 +92,35 @@ public static void ensureWebhookExists(@NotNull IWebhookContainer channel, @NotN * the message */ public static CompletableFuture mirrorMessageToWebhook(@NotNull Webhook webhook, @NotNull Message originalMessage, String newMessageContent, long threadId, @Nullable List components, @Nullable List embeds) { + return mirrorMessageToWebhook(webhook, originalMessage, newMessageContent, threadId, components, null, embeds); + } + + /** + * Resends a specific message using a webhook with a custom content. + * + * @param webhook the webhook used for sending the message + * @param originalMessage the message to copy + * @param newMessageContent the new (custom) content + * @param threadId the thread to send the message in or {@code 0} if the + * message should be sent directly + * @param components A nullable list of {@link LayoutComponent}s. + * @param embeds A nullable list of {@link MessageEmbed}s. + * @param attachments A nullable list of {@link Attachment}s. + * @return a {@link CompletableFuture} representing the action of sending + * the message + */ + public static CompletableFuture mirrorMessageToWebhook(@NotNull Webhook webhook, @NotNull Message originalMessage, String newMessageContent, long threadId, @Nullable List components, @Nullable List attachments, @Nullable List embeds) { return originalMessage .getGuild() .retrieveMember(originalMessage.getAuthor()) .submit() .exceptionally(e -> null)//if the member cannot be found, use no member information - .thenCompose(member -> - mirrorMessageToWebhook(webhook, originalMessage, newMessageContent, threadId, components, embeds, member)); + .thenCompose(member -> + mirrorMessageToWebhook(webhook, originalMessage, newMessageContent, threadId, components, attachments, embeds, member)); } - private static CompletableFuture mirrorMessageToWebhook(@NotNull Webhook webhook, Message originalMessage, String newMessageContent, long threadId, - List components, List embeds, Member member) { + private static CompletableFuture mirrorMessageToWebhook(@NotNull Webhook webhook, Message originalMessage, String newMessageContent, long threadId, List components, + List attachments, List embeds, Member member) { WebhookMessageBuilder message = new WebhookMessageBuilder().setContent(newMessageContent) .setAllowedMentions(AllowedMentions.none()) .setAvatarUrl(transformOrNull(member, Member::getEffectiveAvatarUrl)) @@ -118,11 +137,11 @@ private static CompletableFuture mirrorMessageToWebhook(@NotNul message.addEmbeds(embeds.stream() .map(e -> WebhookEmbedBuilder.fromJDA(e).build()) .toList()); - List attachments = originalMessage.getAttachments(); + List newAttachments = Objects.requireNonNullElse(attachments, originalMessage.getAttachments()); @SuppressWarnings("unchecked") - CompletableFuture[] futures = new CompletableFuture[attachments.size()]; - for (int i = 0; i < attachments.size(); i++) { - Attachment attachment = attachments.get(i); + CompletableFuture[] futures = new CompletableFuture[newAttachments.size()]; + for (int i = 0; i < newAttachments.size(); i++) { + Attachment attachment = newAttachments.get(i); futures[i] = attachment.getProxy() .download() .thenAccept(is -> message.addFile((attachment.isSpoiler() ? "SPOILER_" : "") + attachment.getFileName(), is)); @@ -156,13 +175,18 @@ private static R transformOrNull(T toTransform, Function transforme * @param newMessageContent a String containing the new message's content * @param threadId id of the thread in which the message should be replaced * @param embeds optional additional embeds to be added + * @param attachments optional additional attachments to be added */ - public static void replaceMemberMessage(Webhook webhook, Message originalMessage, String newMessageContent, long threadId, MessageEmbed... embeds) { - WebhookUtil.mirrorMessageToWebhook(webhook, originalMessage, newMessageContent, threadId, null, List.of(embeds)) + public static void replaceMemberMessage(Webhook webhook, Message originalMessage, String newMessageContent, long threadId, List attachments, List embeds) { + WebhookUtil.mirrorMessageToWebhook(webhook, originalMessage, newMessageContent, threadId, null, attachments, embeds) .thenAccept(unused -> originalMessage.delete().queue()) .exceptionally(e -> { ExceptionLogger.capture(e, WebhookUtil.class.getSimpleName()); return null; }); } + + public static void replaceMemberMessage(Webhook webhook, Message originalMessage, String newMessageContent, long threadId, MessageEmbed... embeds) { + replaceMemberMessage(webhook, originalMessage, newMessageContent, threadId, null, List.of(embeds)); + } } From 0872b1031568eb7bb763d4552a763d7c6928574a Mon Sep 17 00:00:00 2001 From: ayy <180436638+ayyhimself@users.noreply.github.com> Date: Sun, 24 Aug 2025 12:25:00 +0200 Subject: [PATCH 022/168] fix --- .../net/discordjug/javabot/data/config/GuildConfig.java | 3 +-- .../filter/BlacklistedMessageAttachmentFilter.java | 2 +- .../javabot/listener/filter/MessageContent.java | 4 ++-- .../discordjug/javabot/listener/filter/MessageFilter.java | 8 ++++---- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/data/config/GuildConfig.java b/src/main/java/net/discordjug/javabot/data/config/GuildConfig.java index 66eb7213d..8822b4705 100644 --- a/src/main/java/net/discordjug/javabot/data/config/GuildConfig.java +++ b/src/main/java/net/discordjug/javabot/data/config/GuildConfig.java @@ -38,7 +38,7 @@ public class GuildConfig { private StarboardConfig starboardConfig; private MessageCacheConfig messageCacheConfig; private ServerLockConfig serverLockConfig; - private List blacklistedMessageExtensions; + private List blacklistedMessageExtensions = List.of("jar", "exe", "zip"); /** * Constructor that initializes all Config classes. @@ -56,7 +56,6 @@ public GuildConfig(Guild guild, Path file) { this.starboardConfig = new StarboardConfig(); this.messageCacheConfig = new MessageCacheConfig(); this.serverLockConfig = new ServerLockConfig(); - this.blacklistedMessageExtensions = List.of("jar", "exe", "zip"); this.setGuild(guild); } diff --git a/src/main/java/net/discordjug/javabot/listener/filter/BlacklistedMessageAttachmentFilter.java b/src/main/java/net/discordjug/javabot/listener/filter/BlacklistedMessageAttachmentFilter.java index fb80ef294..e9db4e99b 100644 --- a/src/main/java/net/discordjug/javabot/listener/filter/BlacklistedMessageAttachmentFilter.java +++ b/src/main/java/net/discordjug/javabot/listener/filter/BlacklistedMessageAttachmentFilter.java @@ -12,7 +12,7 @@ import java.util.List; /** - * This {@link MessageFilter} blocks attachments blacklisted using {@link GuildConfig#getBlacklistedMessageExtensions()}. + * This {@link MessageFilter} blocks attachments blacklisted using {@link GuildConfig}. */ @Component @RequiredArgsConstructor diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageContent.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageContent.java index 53bd21003..de05cd0ad 100644 --- a/src/main/java/net/discordjug/javabot/listener/filter/MessageContent.java +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageContent.java @@ -7,8 +7,8 @@ import java.util.List; /** - * A class storing different parameters of a message sent. - * @param event The event associated with the message + * A class containing modifiable content of a message that has been received. + * @param event The event associated with receiving the message * @param messageText The text associated with the message * @param attachments The attachments associated with the message * @param embeds The embeds associated with the message diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java index dd56679a1..4b9978d56 100644 --- a/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java @@ -3,20 +3,20 @@ /** * This interface is implemented by all message filters. * - * The {@link MessageContent} is processed through every class implementing {@link MessageFilter} - * unless one of the filters returns {@link MessageModificationStatus#STOP_PROCESSING} which stops further filters from processing. + * The {@link MessageContent} is processed by every class implementing {@link MessageFilter} + * unless one of the filters returns {@link MessageModificationStatus#STOP_PROCESSING} which stops further filters from executing. */ public interface MessageFilter { /** - * When a message is received, it is executed on the registered filters. + * When a message is received, it is processed on the registered filters. * * @param content The content of the new message that will be reposted instead of the received message * if at least one filter returns {@link MessageModificationStatus#MODIFIED} * and no filter returns {@link MessageModificationStatus#STOP_PROCESSING}. * This {@link MessageContent} is built up incrementally by the filters. * @return the appropriate {@link MessageModificationStatus} based on the filter's processing. - * @see MessageFilterHandler for the implementation of the filters. + * @see MessageFilterHandler */ MessageModificationStatus processMessage(MessageContent content); From f401e3b6ae88214cb0ab71ca54f0ba6954fdee1e Mon Sep 17 00:00:00 2001 From: ayy <180436638+ayyhimself@users.noreply.github.com> Date: Sun, 24 Aug 2025 13:02:28 +0200 Subject: [PATCH 023/168] fixfix --- .../net/discordjug/javabot/listener/filter/MessageFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java index 4b9978d56..2a7141b21 100644 --- a/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java @@ -9,7 +9,7 @@ public interface MessageFilter { /** - * When a message is received, it is processed on the registered filters. + * When a message is received, it is processed by the registered filters. * * @param content The content of the new message that will be reposted instead of the received message * if at least one filter returns {@link MessageModificationStatus#MODIFIED} From 45611d2a778f8aeeee9cd1865290f8c89dfb7085 Mon Sep 17 00:00:00 2001 From: ayy <180436638+ayyhimself@users.noreply.github.com> Date: Sun, 24 Aug 2025 15:33:51 +0200 Subject: [PATCH 024/168] fixfixfix --- .../javabot/listener/filter/MessageModificationStatus.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageModificationStatus.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageModificationStatus.java index ef58f8775..7a51a733c 100644 --- a/src/main/java/net/discordjug/javabot/listener/filter/MessageModificationStatus.java +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageModificationStatus.java @@ -1,7 +1,7 @@ package net.discordjug.javabot.listener.filter; /** - * This enum describes the result of an {@link MessageFilter} execution and what should be done with the message. + * This enum describes the result of a {@link MessageFilter} execution and what should be done with the message. */ public enum MessageModificationStatus { /** From 37f577538d1bd4cb01f033b54468fb1be0c0a57a Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 25 Aug 2025 19:36:02 +0200 Subject: [PATCH 025/168] update dependencies --- build.gradle.kts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 10b6c78d3..723ddfe16 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,9 +4,9 @@ import com.github.jengelman.gradle.plugins.shadow.transformers.* plugins { java id("com.github.johnrengelman.shadow") version "8.1.1" - id("org.springframework.boot") version "3.5.0" + id("org.springframework.boot") version "3.5.5" id("io.spring.dependency-management") version "1.1.7" - id("org.graalvm.buildtools.native") version "0.10.6" + id("org.graalvm.buildtools.native") version "0.11.0" checkstyle } @@ -27,22 +27,23 @@ repositories { dependencies { testImplementation("org.junit.jupiter:junit-jupiter-api") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") compileOnly("com.google.code.findbugs:jsr305:3.0.2") compileOnly("org.jetbrains:annotations:26.0.2") // DIH4JDA (Command Framework) & JDA implementation("com.github.DynxstyGIT:DIH4JDA:a64b5a9dc5") - implementation("net.dv8tion:JDA:5.5.1") { + implementation("net.dv8tion:JDA:5.6.1") { exclude(module = "opus-java") } // Caffeine (Caching Library) - implementation("com.github.ben-manes.caffeine:caffeine:3.2.0") + implementation("com.github.ben-manes.caffeine:caffeine:3.2.2") implementation("com.google.code.gson:gson:2.13.1") implementation("org.yaml:snakeyaml:2.4") implementation("com.google.re2j:re2j:1.8") - implementation("commons-validator:commons-validator:1.9.0") + implementation("commons-validator:commons-validator:1.10.0") implementation("com.mashape.unirest:unirest-java:1.4.9") @@ -60,7 +61,7 @@ dependencies { testAnnotationProcessor("org.projectlombok:lombok:1.18.38") // Sentry - implementation("io.sentry:sentry:8.13.2") + implementation("io.sentry:sentry:8.20.0") // Spring implementation("org.springframework.boot:spring-boot-starter-web") From 23f1c577de071571277ced86e9e6ad1e590a95e8 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 25 Aug 2025 19:39:28 +0200 Subject: [PATCH 026/168] add workflow permissions --- .github/workflows/build.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 477c95034..e90bf7d5b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,8 @@ on: [push, pull_request, workflow_dispatch] jobs: build: runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v4 - name: Set up JDK 17 @@ -23,6 +25,8 @@ jobs: run: ./gradlew test publish: runs-on: ubuntu-latest + permissions: + contents: read needs: build if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' }} steps: From 2938fa7cce60adaf09c835fa1a75844f1a59addf Mon Sep 17 00:00:00 2001 From: Defective Date: Wed, 3 Sep 2025 21:38:48 +0200 Subject: [PATCH 027/168] Make a HTTP request to all sent attachment URLs --- .../data/h2db/message_cache/MessageCache.java | 45 ++++++++++--------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java index 606fec2fb..329710ada 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java @@ -1,24 +1,5 @@ package net.discordjug.javabot.data.h2db.message_cache; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; -import net.discordjug.javabot.data.config.BotConfig; -import net.discordjug.javabot.data.config.guild.MessageCacheConfig; -import net.discordjug.javabot.data.h2db.message_cache.dao.MessageCacheRepository; -import net.discordjug.javabot.data.h2db.message_cache.model.CachedMessage; -import net.discordjug.javabot.systems.user_commands.IdCalculatorCommand; -import net.discordjug.javabot.util.ExceptionLogger; -import net.discordjug.javabot.util.Responses; -import net.discordjug.javabot.util.TimeUtils; -import net.discordjug.javabot.util.UserUtils; -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.entities.*; -import net.dv8tion.jda.api.entities.Message.Attachment; -import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; -import net.dv8tion.jda.api.interactions.components.buttons.Button; -import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; -import net.dv8tion.jda.api.utils.FileUpload; - import java.io.ByteArrayInputStream; import java.io.InputStream; import java.net.URI; @@ -37,6 +18,28 @@ import org.springframework.dao.DataAccessException; import org.springframework.stereotype.Service; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.data.config.guild.MessageCacheConfig; +import net.discordjug.javabot.data.h2db.message_cache.dao.MessageCacheRepository; +import net.discordjug.javabot.data.h2db.message_cache.model.CachedMessage; +import net.discordjug.javabot.systems.user_commands.IdCalculatorCommand; +import net.discordjug.javabot.util.ExceptionLogger; +import net.discordjug.javabot.util.Responses; +import net.discordjug.javabot.util.TimeUtils; +import net.discordjug.javabot.util.UserUtils; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.Message.Attachment; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.interactions.components.buttons.Button; +import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; +import net.dv8tion.jda.api.utils.FileUpload; + /** * Listens for Incoming Messages and stores them in the Message Cache. */ @@ -105,7 +108,9 @@ public void cache(Message message) { synchronize(); } messageCount++; - cache.add(CachedMessage.of(message)); + CachedMessage cachedMessage = CachedMessage.of(message); + cache.add(cachedMessage); + requestMessageAttachments(cachedMessage); } /** From a114e30efd36a75e5d5b528d7f358971a84e7e0a Mon Sep 17 00:00:00 2001 From: Defective Date: Mon, 8 Sep 2025 01:13:50 +0200 Subject: [PATCH 028/168] Forms system A command to attach form buttons to bot's messages Form data deserialization Store forms in database Use an array of form fields instead of a form data object Move `FormsRepository` and `FormField` to different packages Reading forms from database Open modals on form button interaction Log submissions in a configured channel Allow setting a custom submission message Use an *Empty* value if user ommits a field Ignore case when parsing form field text input styles Store forms' origin messages, their channels, and expiration date Form delete command autocompletion Form delete command Forms closing and reopening commands Form details command Form modification command Check if the form is not closed or expired on submission Reword expiration command parameter description Additional checks in all form commands Don't accept raw JSON data and don't immediately attach forms Added `add-field` form command Form field remove command Form show command Put a limit of 5 components for forms Allow field inserting Forms attach and detach commands Prevent removing the last field from an attached form Javadocs Remove redundant checks from form commands Move some common methods to the form interaction manager Added `hasExpired` method to `FormData` Add `onetime` form parameter Logging form submissions to the database Add a repository method to query forms for a specific `closed` state Include total submissions count in the form details message Additional null checks in the forms repository Prevent further submissions to one-time forms A command to export all form submissions Delete form submissions on form deletion Command to delete user's submissions Fix checkstyle violations --- .../forms/FormInteractionManager.java | 275 +++++++++++++++ .../commands/AddFieldFormSubcommand.java | 103 ++++++ .../forms/commands/AttachFormSubcommand.java | 148 ++++++++ .../forms/commands/CloseFormSubcommand.java | 71 ++++ .../forms/commands/CreateFormSubcommand.java | 64 ++++ .../forms/commands/DeleteFormSubcommand.java | 64 ++++ .../forms/commands/DetachFormSubcommand.java | 105 ++++++ .../forms/commands/DetailsFormSubcommand.java | 105 ++++++ .../forms/commands/FormCommand.java | 40 +++ .../forms/commands/ModifyFormSubcommand.java | 89 +++++ .../commands/RemoveFieldFormSubcommand.java | 93 +++++ .../forms/commands/ReopenFormSubcommand.java | 71 ++++ .../forms/commands/ShowFormSubcommand.java | 59 ++++ .../SubmissionsDeleteFormSubcommand.java | 77 +++++ .../SubmissionsExportFormSubcommand.java | 81 +++++ .../forms/dao/FormsRepository.java | 327 ++++++++++++++++++ .../staff_commands/forms/model/FormData.java | 161 +++++++++ .../staff_commands/forms/model/FormField.java | 95 +++++ .../staff_commands/forms/model/FormUser.java | 44 +++ .../database/migrations/09-08-2025_forms.sql | 23 ++ 20 files changed, 2095 insertions(+) create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java create mode 100644 src/main/resources/database/migrations/09-08-2025_forms.sql diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java new file mode 100644 index 000000000..32395d3da --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java @@ -0,0 +1,275 @@ +package net.discordjug.javabot.systems.staff_commands.forms; + +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.TimeZone; +import java.util.function.Function; + +import lombok.RequiredArgsConstructor; +import net.discordjug.javabot.annotations.AutoDetectableComponentHandler; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormField; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; +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.components.ActionRow; +import net.dv8tion.jda.api.interactions.components.ItemComponent; +import net.dv8tion.jda.api.interactions.components.buttons.Button; +import net.dv8tion.jda.api.interactions.modals.Modal; +import net.dv8tion.jda.api.interactions.modals.ModalMapping; +import xyz.dynxsty.dih4jda.interactions.components.ButtonHandler; +import xyz.dynxsty.dih4jda.interactions.components.ModalHandler; +import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; + +/** + * Handle forms interactions, including buttons and submissions modals. + */ +@AutoDetectableComponentHandler(FormInteractionManager.FORM_COMPONENT_ID) +@RequiredArgsConstructor +public class FormInteractionManager implements ButtonHandler, ModalHandler { + + /** + * Date and time format used in forms. + */ + public static final DateFormat DATE_FORMAT; + + /** + * String representation of the date and time format used in forms. + */ + public static final String DATE_FORMAT_STRING; + + /** + * Component ID used for form buttons and modals. + */ + public static final String FORM_COMPONENT_ID = "modal-form"; + private static final String FORM_NOT_FOUND_MSG = "This form was not found in the database. Please report this to the server staff."; + + private final FormsRepository formsRepo; + + static { + DATE_FORMAT_STRING = "dd/MM/yyyy HH:mm"; + DATE_FORMAT = new SimpleDateFormat(FormInteractionManager.DATE_FORMAT_STRING, Locale.ENGLISH); + DATE_FORMAT.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + /** + * Closes the form, preventing further submissions and disabling associated + * buttons from a message this form is attached to, if any. + * + * @param guild guild this form is located in. + * @param form form to close. + */ + public void closeForm(Guild guild, FormData form) { + formsRepo.closeForm(form); + + if (form.getMessageChannel() != null && form.getMessageId() != null) { + TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel()); + formChannel.retrieveMessageById(form.getMessageId()).queue(msg -> { + mapFormMessageButtons(msg, btn -> { + String cptId = btn.getId(); + String[] split = ComponentIdBuilder.split(cptId); + if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID) + && split[1].equals(Long.toString(form.getId()))) { + return btn.asDisabled(); + } + return btn; + }); + }, t -> {}); + } + } + + @Override + public void handleButton(ButtonInteractionEvent event, Button button) { + long formId = Long.parseLong(ComponentIdBuilder.split(button.getId())[1]); + Optional formOpt = formsRepo.getForm(formId); + if (!formOpt.isPresent()) { + event.reply(FORM_NOT_FOUND_MSG).setEphemeral(true).queue(); + return; + } + FormData form = formOpt.get(); + if (!checkNotClosed(form)) { + event.reply("This form is not accepting new submissions.").setEphemeral(true).queue(); + if (!form.isClosed()) { + closeForm(event.getGuild(), form); + } + return; + } + + if (form.isOnetime() && formsRepo.hasSubmitted(event.getUser(), form)) { + event.reply("You have already submitted this form").setEphemeral(true).queue(); + return; + } + + Modal modal = createFormModal(form); + + event.replyModal(modal).queue(); + } + + @Override + public void handleModal(ModalInteractionEvent event, List values) { + event.deferReply().setEphemeral(true).queue(); + long formId = Long.parseLong(ComponentIdBuilder.split(event.getModalId())[1]); + Optional formOpt = formsRepo.getForm(formId); + if (!formOpt.isPresent()) { + event.reply(FORM_NOT_FOUND_MSG).setEphemeral(true).queue(); + return; + } + + FormData form = formOpt.get(); + + if (!checkNotClosed(form)) { + event.getHook().sendMessage("This form is not accepting new submissions.").queue(); + return; + } + + if (form.isOnetime() && formsRepo.hasSubmitted(event.getUser(), form)) { + event.getHook().sendMessage("You have already submitted this form").queue(); + return; + } + + TextChannel channel = event.getGuild().getTextChannelById(form.getSubmitChannel()); + if (channel == null) { + event.getHook() + .sendMessage("We couldn't receive your submission due to an error. Please contact server staff.") + .queue(); + return; + } + + channel.sendMessageEmbeds(createSubmissionEmbed(form, values, event.getMember())).queue(); + formsRepo.logSubmission(event.getUser(), form); + + event.getHook() + .sendMessage( + form.getSubmitMessage() == null ? "Your submission was received!" : form.getSubmitMessage()) + .queue(); + } + + /** + * Modifies buttons in a message using given function for mapping. + * + * @param msg message to modify buttons in. + * @param mapper mapping function. + */ + public void mapFormMessageButtons(Message msg, Function mapper) { + List components = msg.getActionRows().stream().map(row -> { + ItemComponent[] cpts = row.getComponents().stream().map(cpt -> { + if (cpt instanceof Button btn) { + return mapper.apply(btn); + } + return cpt; + }).toList().toArray(new ItemComponent[0]); + if (cpts.length == 0) { + return null; + } + return ActionRow.of(cpts); + }).filter(Objects::nonNull).toList(); + msg.editMessageComponents(components).queue(); + } + + /** + * Re-opens the form, re-enabling associated buttons in message it's attached + * to, if any. + * + * @param guild guild this form is contained in. + * @param form form to re-open. + */ + public void reopenForm(Guild guild, FormData form) { + formsRepo.reopenForm(form); + + if (form.getMessageChannel() != null && form.getMessageId() != null) { + TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel()); + formChannel.retrieveMessageById(form.getMessageId()).queue(msg -> { + mapFormMessageButtons(msg, btn -> { + String cptId = btn.getId(); + String[] split = ComponentIdBuilder.split(cptId); + if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID) + && split[1].equals(Long.toString(form.getId()))) { + return btn.asEnabled(); + } + return btn; + }); + }, t -> {}); + } + } + + /** + * Creates a submission modal for the given form. + * + * @param form form to open submission modal for. + * @return submission modal to be presented to the user. + */ + public static Modal createFormModal(FormData form) { + Modal modal = Modal.create(ComponentIdBuilder.build(FORM_COMPONENT_ID, form.getId()), form.getTitle()) + .addComponents(form.createComponents()).build(); + return modal; + } + + /** + * Gets expiration time from the slash comamnd event. + * + * @param event slash event to get expiration from. + * @return an optional containing expiration time, + * {@link FormData#EXPIRATION_PERMANENT} if none given, or an empty + * optional if it's invalid. + */ + public static Optional parseExpiration(SlashCommandInteractionEvent event) { + String expirationStr = event.getOption("expiration", null, OptionMapping::getAsString); + Optional expiration; + if (expirationStr == null) { + expiration = Optional.of(FormData.EXPIRATION_PERMANENT); + } else { + try { + expiration = Optional.of(FormInteractionManager.DATE_FORMAT.parse(expirationStr).getTime()); + } catch (ParseException e) { + event.getHook().sendMessage("Invalid date. You should follow the format `" + + FormInteractionManager.DATE_FORMAT_STRING + "`.").setEphemeral(true).queue(); + expiration = Optional.empty(); + } + } + + if (expiration.isPresent() && expiration.get() != FormData.EXPIRATION_PERMANENT + && expiration.get() < System.currentTimeMillis()) { + event.getHook().sendMessage("The expiration date shouldn't be in the past").setEphemeral(true).queue(); + return Optional.empty(); + } + return expiration; + } + + private static boolean checkNotClosed(FormData data) { + if (data.isClosed() || data.hasExpired()) { + return false; + } + + return true; + } + + private static MessageEmbed createSubmissionEmbed(FormData form, List values, Member author) { + EmbedBuilder builder = new EmbedBuilder().setTitle("New form submission received") + .setAuthor(author.getEffectiveName(), null, author.getEffectiveAvatarUrl()).setTimestamp(Instant.now()); + builder.addField("Sender", author.getAsMention(), true).addField("Title", form.getTitle(), true); + + int len = Math.min(values.size(), form.getFields().size()); + for (int i = 0; i < len; i++) { + ModalMapping mapping = values.get(i); + FormField field = form.getFields().get(i); + String value = mapping.getAsString(); + builder.addField(field.getLabel(), value == null ? "*Empty*" : "```\n" + value + "\n```", false); + } + + return builder.build(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java new file mode 100644 index 000000000..e29885c56 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java @@ -0,0 +1,103 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Arrays; +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormField; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form add-field` command. + */ +public class AddFieldFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public AddFieldFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("add-field", "Adds a field to an existing form") + .addOption(OptionType.INTEGER, "form-id", "Form ID to add the field to", true, true) + .addOption(OptionType.STRING, "label", "Field label", true) + .addOption(OptionType.INTEGER, "min", "Minimum number of characters") + .addOption(OptionType.INTEGER, "max", "Maximum number of characters") + .addOption(OptionType.STRING, "placeholder", "Field placeholder") + .addOption(OptionType.BOOLEAN, "required", + "Whether or not the user has to input data in this field. Default: false") + .addOption(OptionType.STRING, "style", "Input style. Default: SHORT", false, true) + .addOption(OptionType.STRING, "value", "Initial field value") + .addOption(OptionType.INTEGER, "index", "Index to insert the field at")); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + event.deferReply(true).queue(); + Optional formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + + if (form.getFields().size() >= 5) { + event.getHook().sendMessage("Can't add more than 5 components to a form").queue(); + return; + } + + int index = event.getOption("index", -1, OptionMapping::getAsInt); + if (index < -1 || index >= form.getFields().size()) { + event.getHook().sendMessage("Field index out of bounds").queue(); + return; + } + + formsRepo.addField(form, createFormFieldFromEvent(event), index); + event.getHook().sendMessage("Added a new field to the form.").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + switch (target.getName()) { + case "form-id" -> event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + case "style" -> + event.replyChoices(Arrays.stream(TextInputStyle.values()).filter(t -> t != TextInputStyle.UNKNOWN) + .map(style -> new Choice(style.name(), style.name())).toList()).queue(); + default -> {} + } + } + + private static FormField createFormFieldFromEvent(SlashCommandInteractionEvent e) { + String label = e.getOption("label", OptionMapping::getAsString); + int min = e.getOption("min", 0, OptionMapping::getAsInt); + int max = e.getOption("max", 64, OptionMapping::getAsInt); + String placeholder = e.getOption("placeholder", OptionMapping::getAsString); + boolean required = e.getOption("required", false, OptionMapping::getAsBoolean); + TextInputStyle style = e.getOption("style", TextInputStyle.SHORT, t -> { + try { + return TextInputStyle.valueOf(t.getAsString().toUpperCase()); + } catch (IllegalArgumentException e2) { + return TextInputStyle.SHORT; + } + }); + String value = e.getOption("value", OptionMapping::getAsString); + + return new FormField(label, max, min, placeholder, required, style.name(), value); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java new file mode 100644 index 000000000..0c31e0ed5 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java @@ -0,0 +1,148 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +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 net.dv8tion.jda.api.interactions.components.ActionRow; +import net.dv8tion.jda.api.interactions.components.ItemComponent; +import net.dv8tion.jda.api.interactions.components.buttons.Button; +import net.dv8tion.jda.api.interactions.components.buttons.ButtonStyle; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; +import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; + +/** + * The `/form attach` command. + */ +public class AttachFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public AttachFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("attach", "Attach a form to a message").addOptions( + new OptionData(OptionType.INTEGER, "form-id", "ID of the form to attach", true, true), + new OptionData(OptionType.STRING, "message-id", "ID of the message to attach the form to", true), + new OptionData(OptionType.CHANNEL, "channel", + "Channel of the message. Required if the message is in a different channel"), + new OptionData(OptionType.STRING, "button-label", "Label of the submit button. Default is \"Submit\""), + new OptionData(OptionType.STRING, "button-style", "Submit button style. Defaults to primary", false, + true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + event.deferReply().setEphemeral(true).queue(); + + Optional formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + + if (form.getMessageChannel() != null && form.getMessageId() != null) { + event.getHook() + .sendMessage("The form seems to already be attached to a message. Detach it before continuing.") + .queue(); + return; + } + + if (form.getFields().isEmpty()) { + event.getHook().sendMessage("You can't attach a form with no fields.").queue(); + return; + } + + String messageId = event.getOption("message-id", OptionMapping::getAsString); + GuildChannel channel = event.getOption("channel", event.getChannel().asGuildMessageChannel(), + OptionMapping::getAsChannel); + + if (channel == null) { + event.getHook().sendMessage("A channel with this ID was not found.").setEphemeral(true).queue(); + return; + } + + if (!(channel instanceof MessageChannel msgChannel)) { + event.getHook().sendMessage("You must specify a message channel").setEphemeral(true).queue(); + return; + } + + String buttonLabel = event.getOption("button-label", "Submit", OptionMapping::getAsString); + ButtonStyle style = event.getOption("button-style", ButtonStyle.PRIMARY, t -> { + try { + return ButtonStyle.valueOf(t.getAsString().toUpperCase()); + } catch (IllegalArgumentException e) { + return ButtonStyle.PRIMARY; + } + }); + + msgChannel.retrieveMessageById(messageId).queue(message -> { + attachFormToMessage(message, buttonLabel, style, form); + formsRepo.attachForm(form, msgChannel, message); + event.getHook() + .sendMessage("Successfully attached the form to the [message](" + message.getJumpUrl() + ")!") + .queue(); + }, t -> event.getHook().sendMessage("A message with this ID was not found").queue()); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + switch (target.getName()) { + case "form-id" -> event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + case "button-style" -> event.replyChoices( + Set.of(ButtonStyle.DANGER, ButtonStyle.PRIMARY, ButtonStyle.SECONDARY, ButtonStyle.SUCCESS).stream() + .map(style -> new Choice(style.name(), style.name())).toList()) + .queue(); + default -> {} + } + } + + private static void attachFormToMessage(Message message, String buttonLabel, ButtonStyle style, FormData form) { + List rows = new ArrayList<>(message.getActionRows()); + + Button button = Button.of(style, + ComponentIdBuilder.build(FormInteractionManager.FORM_COMPONENT_ID, form.getId()), buttonLabel); + + if (form.isClosed() || form.hasExpired()) { + button = button.asDisabled(); + } + + if (rows.isEmpty() || rows.get(rows.size() - 1).getActionComponents().size() >= 5) { + rows.add(ActionRow.of(button)); + } else { + ActionRow lastRow = rows.get(rows.size() - 1); + ItemComponent[] components = new ItemComponent[lastRow.getComponents().size() + 1]; + System.arraycopy(lastRow.getComponents().toArray(new ItemComponent[0]), 0, components, 0, + lastRow.getComponents().size()); + components[components.length - 1] = button; + rows.set(rows.size() - 1, ActionRow.of(components)); + } + + message.editMessageComponents(rows.toArray(new ActionRow[0])).queue(); + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java new file mode 100644 index 000000000..3c24e0079 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java @@ -0,0 +1,71 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Optional; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +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 xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form close` command. + */ +public class CloseFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + private final FormInteractionManager interactionManager; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param interactionManager form interaction manager + * @param botConfig main bot configuration + */ + public CloseFormSubcommand(FormsRepository formsRepo, FormInteractionManager interactionManager, + BotConfig botConfig) { + this.formsRepo = formsRepo; + this.interactionManager = interactionManager; + setCommandData(new SubcommandData("close", "Close an existing form") + .addOptions(new OptionData(OptionType.INTEGER, "form-id", "The ID of a form to close", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + long id = event.getOption("form-id", OptionMapping::getAsLong); + Optional formOpt = formsRepo.getForm(id); + if (formOpt.isEmpty()) { + event.reply("A form with this ID was not found.").setEphemeral(true).queue(); + return; + } + FormData form = formOpt.get(); + + if (form.isClosed()) { + event.reply("This form is already closed").setEphemeral(true).queue(); + return; + } + + event.deferReply(true).queue(); + + interactionManager.closeForm(event.getGuild(), form); + + event.getHook().sendMessage("Form closed!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + event.replyChoices( + formsRepo.getAllForms(false).stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java new file mode 100644 index 000000000..aab91e071 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java @@ -0,0 +1,64 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.List; +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +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 xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form create` command. + */ +public class CreateFormSubcommand extends Subcommand { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public CreateFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("create", "Create a new form").addOptions( + new OptionData(OptionType.STRING, "title", "Form title (shown in modal)", true), + new OptionData(OptionType.CHANNEL, "submit-channel", "Channel to log form submissions in", true), + new OptionData(OptionType.STRING, "submit-message", + "Message displayed to the user once they submit the form"), + new OptionData(OptionType.STRING, "expiration", + "UTC time after which the form will not accept further submissions. " + + FormInteractionManager.DATE_FORMAT_STRING), + new OptionData(OptionType.BOOLEAN, "onetime", + "If the form should only accept one submission per user. Defaults to false."))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + event.deferReply().setEphemeral(true).queue(); + String expirationStr = event.getOption("expiration", null, OptionMapping::getAsString); + Optional expirationOpt = FormInteractionManager.parseExpiration(event); + + if (expirationOpt.isEmpty()) return; + + long expiration = expirationOpt.get(); + + long formId = System.currentTimeMillis(); + FormData form = new FormData(formId, List.of(), event.getOption("title", OptionMapping::getAsString), + event.getOption("submit-channel", OptionMapping::getAsChannel).getId(), + event.getOption("submit-message", null, OptionMapping::getAsString), null, null, expiration, false, + event.getOption("onetime", false, OptionMapping::getAsBoolean)); + + formsRepo.insertForm(form); + event.getHook() + .sendMessage("The form was created! Remember to add fields to it before attaching it to a message.") + .queue(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java new file mode 100644 index 000000000..a368ab5b4 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java @@ -0,0 +1,64 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +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 xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form delete` command. + */ +public class DeleteFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public DeleteFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("delete", "Delete an existing form") + .addOptions(new OptionData(OptionType.INTEGER, "form-id", "The ID of a form to delete", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + long id = event.getOption("form-id", OptionMapping::getAsLong); + Optional formOpt = formsRepo.getForm(id); + if (formOpt.isEmpty()) { + event.reply("A form with this ID was not found.").setEphemeral(true).queue(); + return; + } + + event.deferReply(true).queue(); + + FormData form = formOpt.get(); + formsRepo.deleteForm(form); + + if (form.getMessageChannel() != null && form.getMessageId() != null) { + DetachFormSubcommand.detachFromMessage(form, event.getGuild()); + } + + event.getHook().sendMessage("Form deleted!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java new file mode 100644 index 000000000..b40446972 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java @@ -0,0 +1,105 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +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 net.dv8tion.jda.api.interactions.components.ActionRow; +import net.dv8tion.jda.api.interactions.components.ItemComponent; +import net.dv8tion.jda.api.interactions.components.buttons.Button; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; +import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; + +/** + * The `/form detach` command. + */ +public class DetachFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public DetachFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("detach", "Detach a form from a message") + .addOptions(new OptionData(OptionType.INTEGER, "form-id", "ID of the form to attach", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + event.deferReply().setEphemeral(true).queue(); + + Optional formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + + if (form.getMessageChannel() == null && form.getMessageId() == null) { + event.getHook().sendMessage("This form doesn't seem to be attached to a message").queue(); + return; + } + + detachFromMessage(form, event.getGuild()); + formsRepo.detachForm(form); + + event.getHook().sendMessage("Form detached!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + } + + /** + * Detaches the form from a message it's attached to, deleting any associated + * buttons. Fails silently if the message was not found. + * + * @param form form to detach + * @param guild guild this form is contained in + */ + public static void detachFromMessage(FormData form, Guild guild) { + TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel()); + formChannel.retrieveMessageById(form.getMessageId()).queue(msg -> { + List components = msg.getActionRows().stream().map(row -> { + ItemComponent[] cpts = row.getComponents().stream().filter(cpt -> { + if (cpt instanceof Button btn) { + String cptId = btn.getId(); + String[] split = ComponentIdBuilder.split(cptId); + if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID)) { + return !split[1].equals(Long.toString(form.getId())); + } + } + return true; + }).toList().toArray(new ItemComponent[0]); + if (cpts.length == 0) { + return null; + } + return ActionRow.of(cpts); + }).filter(Objects::nonNull).toList(); + msg.editMessageComponents(components).queue(); + }, t -> {}); + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java new file mode 100644 index 000000000..008381f56 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java @@ -0,0 +1,105 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.time.Instant; +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +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 net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; +import net.dv8tion.jda.api.utils.messages.MessageCreateData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form details` command. + */ +public class DetailsFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public DetailsFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("details", "Get details about a form").addOptions( + new OptionData(OptionType.INTEGER, "form-id", "The ID of a form to get details for", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + event.deferReply().setEphemeral(false).queue(); + Optional formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("Couldn't find a form with this id").queue(); + return; + } + + FormData form = formOpt.get(); + EmbedBuilder embedBuilder = createFormDetailsEmbed(form, event.getGuild()); + embedBuilder.setAuthor(event.getMember().getEffectiveName(), null, event.getMember().getEffectiveAvatarUrl()); + embedBuilder.setTimestamp(Instant.now()); + + MessageCreateData builder = new MessageCreateBuilder().addEmbeds(embedBuilder.build()).build(); + + event.getHook().sendMessage(builder).queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + } + + private EmbedBuilder createFormDetailsEmbed(FormData form, Guild guild) { + EmbedBuilder builder = new EmbedBuilder().setTitle("Form details"); + + long id = form.getId(); + + addCodeblockField(builder, "ID", id, true); + builder.addField("Created at", String.format("", id / 1000L), true); + + String expiration; + builder.addField("Expires at", + form.hasExpirationTime() ? String.format("", form.getExpiration() / 1000L) : "`Never`", true); + + addCodeblockField(builder, "State", form.isClosed() ? "Closed" : form.hasExpired() ? "Expired" : "Open", false); + + builder.addField("Attached in", + form.getMessageChannel() == null ? "*Not attached*" : "<#" + form.getMessageChannel() + ">", true); + builder.addField("Attached to", + form.getMessageChannel() == null || form.getMessageId() == null ? "*Not attached*" + : String.format("[Link](https://discord.com/channels/%s/%s/%s)", guild.getId(), + form.getMessageChannel(), form.getMessageId()), + true); + + builder.addField("Submissions channel", "<#" + form.getSubmitChannel() + ">", true); + builder.addField("Is one-time", form.isOnetime() ? ":white_check_mark:" : ":x:", true); + addCodeblockField(builder, "Submission message", + form.getSubmitMessage() == null ? "Default" : form.getSubmitMessage(), true); + + addCodeblockField(builder, "Number of fields", form.getFields().size(), true); + addCodeblockField(builder, "Number of submissions", formsRepo.getTotalSubmissionsCount(form), true); + + return builder; + } + + private static void addCodeblockField(EmbedBuilder builder, String name, Object content, boolean inline) { + builder.addField(name, String.format("```\n%s\n```", content), inline); + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java new file mode 100644 index 000000000..2a2dc94cf --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java @@ -0,0 +1,40 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions; +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; + +/** + * The `/form` command. + */ +public class FormCommand extends SlashCommand { + + /** + * The main constructor of this subcommand. + * + * @param createSub form create subcommand + * @param deleteSub form delete subcommand + * @param closeSub form close subcommand + * @param reopenSub form reopen subcommand + * @param detailsSub form details subcommand + * @param modifySub form modify subcommand + * @param addFieldSub form add-field subcommand + * @param removeFieldSub form remove-field subcommand + * @param showSub form show subcommands + * @param attachSub form attach subcommand + * @param detachSub form detach subcommand + * @param submissionsGetSub form submissions-get subcommand + * @param submissionsDeleteSub form submissions-delete subcommand + * + */ + public FormCommand(CreateFormSubcommand createSub, DeleteFormSubcommand deleteSub, CloseFormSubcommand closeSub, + ReopenFormSubcommand reopenSub, DetailsFormSubcommand detailsSub, ModifyFormSubcommand modifySub, + AddFieldFormSubcommand addFieldSub, RemoveFieldFormSubcommand removeFieldSub, ShowFormSubcommand showSub, + AttachFormSubcommand attachSub, DetachFormSubcommand detachSub, + SubmissionsExportFormSubcommand submissionsGetSub, SubmissionsDeleteFormSubcommand submissionsDeleteSub) { + setCommandData(Commands.slash("form", "Commands for managing modal forms") + .setDefaultPermissions(DefaultMemberPermissions.DISABLED).setGuildOnly(true)); + addSubcommands(createSub, deleteSub, closeSub, reopenSub, detailsSub, modifySub, addFieldSub, removeFieldSub, + showSub, attachSub, detachSub, submissionsGetSub, submissionsDeleteSub); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java new file mode 100644 index 000000000..da4dd39a9 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java @@ -0,0 +1,89 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +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 xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form modify` command. + */ +public class ModifyFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public ModifyFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("modify", "Modify an existing form").addOptions( + new OptionData(OptionType.INTEGER, "form-id", "ID of the form to modify", true, true), + new OptionData(OptionType.STRING, "title", "Form title (shown in modal)"), + new OptionData(OptionType.STRING, "json", "Form inputs data"), + new OptionData(OptionType.CHANNEL, "submit-channel", "Channel to log form submissions in"), + new OptionData(OptionType.STRING, "submit-message", + "Message displayed to the user once they submit the form"), + new OptionData(OptionType.STRING, "expiration", + "UTC time after which the form will not accept further submissions. " + + FormInteractionManager.DATE_FORMAT_STRING), + new OptionData(OptionType.BOOLEAN, "onetime", + "If the form should only accept one submission per user. Defaults to false."))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + event.deferReply(true).queue(); + Optional formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("Couldn't find a form with this ID").queue(); + return; + } + FormData oldForm = formOpt.get(); + + String title = event.getOption("title", oldForm.getTitle(), OptionMapping::getAsString); + String submitChannel = event.getOption("submit-channel", oldForm.getSubmitChannel(), + OptionMapping::getAsString); + String submitMessage = event.getOption("submit-message", oldForm.getSubmitMessage(), + OptionMapping::getAsString); + long expiration; + if (event.getOption("expiration") == null) { + expiration = oldForm.getExpiration(); + } else { + Optional expirationOpt = FormInteractionManager.parseExpiration(event); + if (expirationOpt.isEmpty()) return; + expiration = expirationOpt.get(); + } + + boolean onetime = event.getOption("onetime", oldForm.isOnetime(), OptionMapping::getAsBoolean); + + FormData newForm = new FormData(oldForm.getId(), oldForm.getFields(), title, submitChannel, submitMessage, + oldForm.getMessageId(), oldForm.getMessageChannel(), expiration, oldForm.isClosed(), onetime); + + formsRepo.updateForm(newForm); + + event.getHook().sendMessage("Form updated!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java new file mode 100644 index 000000000..bcbd4dc1b --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java @@ -0,0 +1,93 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormField; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form remove-field` command. + */ +public class RemoveFieldFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public RemoveFieldFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("remove-field", "Removse a field from an existing form") + .addOption(OptionType.INTEGER, "form-id", "Form ID to add the field to", true, true) + .addOption(OptionType.INTEGER, "field", "# of the field to remove", true, true)); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + event.deferReply(true).queue(); + Optional formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong)); + int index = event.getOption("field", OptionMapping::getAsInt); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + if (index < 0 || index >= form.getFields().size()) { + event.getHook().sendMessage("Field index out of bounds.").queue(); + return; + } + + if (form.getMessageChannel() != null && form.getMessageId() != null && form.getFields().size() <= 1) { + event.getHook().sendMessage( + "Can't remove the last field from an attached form. Detach the form before removing the field") + .queue(); + return; + } + + formsRepo.removeField(form, index); + + event.getHook().sendMessage("Removed field `" + form.getFields().get(index).getLabel() + "` from the form.") + .queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + switch (target.getName()) { + case "form-id" -> event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + case "field" -> { + Long formId = event.getOption("form-id", OptionMapping::getAsLong); + if (formId != null) { + Optional form = formsRepo.getForm(formId); + if (form.isPresent()) { + List choices = new ArrayList<>(); + List fields = form.get().getFields(); + for (int i = 0; i < fields.size(); i++) { + choices.add(new Choice(fields.get(i).getLabel(), i)); + } + event.replyChoices(choices).queue(); + return; + } + } + event.replyChoices().queue(); + } + default -> {} + } + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java new file mode 100644 index 000000000..3b130773b --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java @@ -0,0 +1,71 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Optional; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +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 xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form reopen` command. + */ +public class ReopenFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + private final FormInteractionManager interactionManager; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param interactionManager form interaction manager + * @param botConfig main bot configuration + */ + public ReopenFormSubcommand(FormsRepository formsRepo, FormInteractionManager interactionManager, + BotConfig botConfig) { + this.formsRepo = formsRepo; + this.interactionManager = interactionManager; + setCommandData(new SubcommandData("reopen", "Reopen a closed form").addOptions( + new OptionData(OptionType.INTEGER, "form-id", "The ID of a closed form to reopen", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + long id = event.getOption("form-id", OptionMapping::getAsLong); + Optional formOpt = formsRepo.getForm(id); + if (formOpt.isEmpty()) { + event.reply("A form with this ID was not found.").setEphemeral(true).queue(); + return; + } + FormData form = formOpt.get(); + + if (!form.isClosed()) { + event.reply("This form is already opened").setEphemeral(true).queue(); + return; + } + + event.deferReply(true).queue(); + + interactionManager.reopenForm(event.getGuild(), form); + + event.getHook().sendMessage("Form reopened!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + event.replyChoices( + formsRepo.getAllForms(true).stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java new file mode 100644 index 000000000..c701576d1 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java @@ -0,0 +1,59 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form show` command. + */ +public class ShowFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public ShowFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("show", + "Forcefully opens a form dialog, even if it's closed, or not attached to a message") + .addOption(OptionType.INTEGER, "form-id", "Form ID to add the field to", true, true)); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + Optional formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.reply("A form with this ID was not found.").setEphemeral(true).queue(); + return; + } + FormData form = formOpt.get(); + if (form.getFields().isEmpty()) { + event.reply("You can't open a form with no fields").setEphemeral(true).queue(); + return; + } + event.replyModal(FormInteractionManager.createFormModal(form)).queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java new file mode 100644 index 000000000..c0334b264 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java @@ -0,0 +1,77 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +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 xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form submissions-delete` command. + */ +public class SubmissionsDeleteFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public SubmissionsDeleteFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData( + new SubcommandData("submissions-delete", "Deletes submissions of an user in the form").addOptions( + new OptionData(OptionType.INTEGER, "form-id", "The ID of a form to get submissions for", true, + true), + new OptionData(OptionType.STRING, "user-id", "User to delete submissions of", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + event.deferReply().setEphemeral(true).queue(); + Optional formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("Couldn't find a form with this id").queue(); + return; + } + + String user = event.getOption("user-id", OptionMapping::getAsString); + FormData form = formOpt.get(); + + int count = formsRepo.deleteSubmissions(form, user); + event.getHook().sendMessage("Deleted " + count + " of this user's submissions!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + switch (target.getName()) { + case "user-id" -> { + Long formId = event.getOption("form-id", OptionMapping::getAsLong); + if (formId != null) { + Optional form = formsRepo.getForm(formId); + if (form.isPresent()) { + event.replyChoices(formsRepo.getAllSubmissions(form.get()).keySet().stream() + .map(user -> new Choice(user.getUsername(), Long.toString(user.getId()))).toList()) + .queue(); + return; + } + } + event.replyChoices().queue(); + } + case "form-id" -> event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + default -> {} + } + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java new file mode 100644 index 000000000..f28f2c716 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java @@ -0,0 +1,81 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormUser; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +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 net.dv8tion.jda.api.utils.FileUpload; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form submissions-export` command. + */ +public class SubmissionsExportFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public SubmissionsExportFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("submissions-export", "Export all of the form's submissions").addOptions( + new OptionData(OptionType.INTEGER, "form-id", "The ID of a form to get submissions for", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + event.deferReply().setEphemeral(false).queue(); + Optional formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("Couldn't find a form with this id").queue(); + return; + } + + FormData form = formOpt.get(); + Map submissions = formsRepo.getAllSubmissions(form); + JsonObject root = new JsonObject(); + JsonObject details = new JsonObject(); + JsonArray users = new JsonArray(); + for (Entry entry : submissions.entrySet()) { + JsonObject uobj = new JsonObject(); + uobj.addProperty("username", entry.getKey().getUsername()); + uobj.addProperty("submissions", entry.getValue()); + details.add(Long.toString(entry.getKey().getId()), uobj); + users.add(entry.getKey().getUsername()); + } + root.add("users", users); + root.add("details", details); + event.getHook().sendFiles(FileUpload.fromData(gson.toJson(root).getBytes(StandardCharsets.UTF_8), + "submissions_" + form.getId() + ".json")).queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java new file mode 100644 index 000000000..1241c8dc7 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -0,0 +1,327 @@ +package net.discordjug.javabot.systems.staff_commands.forms.dao; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.PreparedStatementCreator; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormField; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormUser; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; + +/** + * Dao class that represents the FORMS database. + */ +@RequiredArgsConstructor +@Repository +public class FormsRepository { + private final Gson gson; + private final JdbcTemplate jdbcTemplate; + + /** + * Add a field to a form. + * + * @param form form to add field to + * @param field field to add + * @param index insertion index, or -1 to append + */ + public void addField(FormData form, FormField field, int index) { + Objects.requireNonNull(field); + List fields = new ArrayList<>(form.getFields()); + if (index != -1) { + fields.add(index, field); + } else { + fields.add(field); + } + updateFormData(form, fields); + } + + /** + * Attaches a form to a message. + * + * @param form form to attach + * @param message message to attach the form to + * @param channel channel of the message + */ + public void attachForm(FormData form, MessageChannel channel, Message message) { + Objects.requireNonNull(form); + Objects.requireNonNull(channel); + Objects.requireNonNull(message); + jdbcTemplate.update("update `forms` set `message_id` = ?, `message_channel` = ? where `form_id` = ?", + message.getId(), channel.getId(), form.getId()); + } + + /** + * Set this form's closed state to true. + * + * @param form form to close + */ + public void closeForm(FormData form) { + jdbcTemplate.update("update `forms` set `closed` = true where `form_id` = ?", form.getId()); + } + + /** + * Deletes a form from the database. + * + * @param form form to delete + */ + public void deleteForm(FormData form) { + jdbcTemplate.update("delete from `forms` where `form_id` = ?", form.getId()); + deleteSubmissions(form); + } + + /** + * Deletes all submissions for this form. + * + * @param form form to delete submissions for. + */ + public void deleteSubmissions(FormData form) { + Objects.requireNonNull(form); + jdbcTemplate.update("delete from `form_submissions` where `form_id` = ?", form.getId()); + } + + /** + * Deletes user's submissions from this form. + * + * @param form form to delete submissions for + * @param user user to delete submissions for + * @return number of deleted submissions + */ + public int deleteSubmissions(FormData form, String user) { + Objects.requireNonNull(form); + Objects.requireNonNull(user); + return jdbcTemplate.update("delete from `form_submissions` where `form_id` = ? and `user_id` = ?", form.getId(), + user); + } + + /** + * Detaches a form from a message. + * + * @param form form to detach + */ + public void detachForm(FormData form) { + Objects.requireNonNull(form); + jdbcTemplate.update("update `forms` set `message_id` = NULL, `message_channel` = NULL where `form_id` = ?", + form.getId()); + } + + /** + * Get all forms from the database. + * + * @return A list of forms + */ + public List getAllForms() { + return jdbcTemplate.query("select * from `forms`", (rs, rowNum) -> read(rs)); + } + + /** + * Get all forms matching given closed state. + * + * @param closed the closed state + * @return A list of forms matching the closed state + */ + public List getAllForms(boolean closed) { + return jdbcTemplate.query(con -> { + PreparedStatement statement = con.prepareStatement("select * from `forms` where `closed` = ?"); + statement.setBoolean(1, closed); + return statement; + }, (rs, rowNum) -> read(rs)); + } + + /** + * Get all submissions of this form in an user -> count map. + * + * @param form a form to get submissions for + * @return a map of users and the number of their submissions + */ + public Map getAllSubmissions(FormData form) { + Objects.requireNonNull(form); + List users = jdbcTemplate.query("select * from `form_submissions` where `form_id` = ?", + (rs, rowNum) -> new FormUser(rs.getLong("user_id"), rs.getString("user_name")), form.getId()); + Map map = new HashMap<>(); + for (FormUser user : users) { + map.compute(user, (t, u) -> u == null ? 1 : u + 1); + } + return Collections.unmodifiableMap(map); + } + + /** + * Get a form for given ID. + * + * @param formId form ID to query + * @return optional containing the form, or empty if the form was not found. + */ + public Optional getForm(long formId) { + try { + return Optional.of(jdbcTemplate.queryForObject("select * from `forms` where `form_id` = ?", + (RowMapper) (rs, rowNum) -> read(rs), formId)); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + /** + * Get a count of logged submissions for the given form. + * + * @param form form to get submission for + * @return A total number of logged submission + */ + public int getTotalSubmissionsCount(FormData form) { + Objects.requireNonNull(form); + return jdbcTemplate.queryForObject("select count(*) from `form_submissions` where `form_id` = ?", + (rs, rowNum) -> rs.getInt(1), form.getId()); + } + + /** + * Checks if an user already submitted the form. + * + * @param user user to check + * @param form form to check on + * @return true if the user has submitted at leas one submission, false + * otherwise + */ + public boolean hasSubmitted(User user, FormData form) { + try { + return jdbcTemplate.queryForObject( + "select * from `form_submissions` where `user_id` = ? and `form_id` = ? limit 1", + (rs, rowNum) -> true, user.getId(), form.getId()); + } catch (EmptyResultDataAccessException e) { + return false; + } + } + + /** + * Create a new form entry in the database. + * + * @param data form data to insert. + */ + public void insertForm(@NonNull FormData data) { + Objects.requireNonNull(data); + jdbcTemplate.update(new PreparedStatementCreator() { + + @Override + public PreparedStatement createPreparedStatement(Connection con) throws SQLException { + PreparedStatement statement = con.prepareStatement( + "merge into `forms` (form_id, form_data, title, submit_message, submit_channel, message_id, message_channel, expiration, onetime) values (?, ?, ?, ?, ?, ?, ? ,?, ?)"); + statement.setLong(1, data.getId()); + statement.setString(2, gson.toJson(data.getFields())); + statement.setString(3, data.getTitle()); + statement.setString(4, data.getSubmitMessage()); + statement.setString(5, data.getSubmitChannel()); + statement.setString(6, data.getMessageId()); + statement.setString(7, data.getMessageChannel()); + statement.setLong(8, data.getExpiration()); + statement.setBoolean(9, data.isOnetime()); + return statement; + } + }); + } + + /** + * Log an user form submission in database. + * + * @param user user to log + * @param form form to log on + */ + public void logSubmission(User user, FormData form) { + Objects.requireNonNull(user); + Objects.requireNonNull(form); + jdbcTemplate.update(con -> { + PreparedStatement statement = con.prepareStatement( + "merge into `form_submissions` (\"timestamp\", `user_id`, `form_id`, `user_name`) values (?, ?, ?, ?)"); + statement.setLong(1, System.currentTimeMillis()); + statement.setString(2, user.getId()); + statement.setLong(3, form.getId()); + statement.setString(4, user.getName()); + return statement; + }); + } + + /** + * Remove a field from a form. Fails silently if the index is out of bounds. + * + * @param form form to remove the field from + * @param index index of the field to remove + */ + public void removeField(FormData form, int index) { + List fields = new ArrayList<>(form.getFields()); + if (index < 0 || index >= fields.size()) return; + fields.remove(index); + updateFormData(form, fields); + } + + /** + * Set this form's closed state to false. + * + * @param form form to re-open + */ + public void reopenForm(FormData form) { + jdbcTemplate.update("update `forms` set `closed` = false where `form_id` = ?", form.getId()); + } + + /** + * Synchronizes form object's values with fields in database. + * + * @param newData new form data. A form with matching ID will be updated in the + * database. + */ + public void updateForm(FormData newData) { + Objects.requireNonNull(newData); + jdbcTemplate.update(con -> { + PreparedStatement statement = con.prepareStatement( + "update `forms` set `title` = ?, `submit_channel` = ?, `submit_message` = ?, `expiration` = ?, `onetime` = ? where `form_id` = ?"); + statement.setString(1, newData.getTitle()); + statement.setString(2, newData.getSubmitChannel()); + statement.setString(3, newData.getSubmitMessage()); + statement.setLong(4, newData.getExpiration()); + statement.setBoolean(5, newData.isOnetime()); + statement.setLong(6, newData.getId()); + return statement; + }); + } + + private FormData read(ResultSet rs) throws SQLException { + List fields = new ArrayList<>(); + for (JsonElement element : JsonParser.parseString(rs.getString("form_data")).getAsJsonArray()) { + fields.add(gson.fromJson(element, FormField.class)); + } + return new FormData(rs.getLong("form_id"), fields, rs.getString("title"), rs.getString("submit_channel"), + rs.getString("submit_message"), rs.getString("message_id"), rs.getString("message_channel"), + rs.getLong("expiration"), rs.getBoolean("closed"), rs.getBoolean("onetime")); + } + + private void updateFormData(FormData form, List fields) { + Objects.requireNonNull(form); + Objects.requireNonNull(fields); + String json = gson.toJson(fields); + jdbcTemplate.update(con -> { + PreparedStatement statement = con + .prepareStatement("update `forms` set `form_data` = ? where `form_id` = ?"); + statement.setString(1, json); + statement.setLong(2, form.getId()); + return statement; + }); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java new file mode 100644 index 000000000..33472d773 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java @@ -0,0 +1,161 @@ +package net.discordjug.javabot.systems.staff_commands.forms.model; + +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Objects; + +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.dv8tion.jda.api.interactions.components.ActionRow; +import net.dv8tion.jda.api.interactions.components.LayoutComponent; + +/** + * Class containing information about a form. + */ +public class FormData { + + /** + * Setting {@link FormData#expiration} to this value indicates, that the form + * will never expire. + */ + public static final long EXPIRATION_PERMANENT = -1; + + private final boolean closed; + private final long expiration; + private final List fields; + private final long id; + private final String messageId; + private final String messageChannel; + private final boolean onetime; + private final String submitChannel; + private final String submitMessage; + private final String title; + + /** + * Main constructor. + * + * @param id The id of this form. The id should be equal to + * timestamp of creation of this form. + * @param fields List of text inputs of this form. + * @param title Form title shown in the submission modal and in various + * commands. + * @param submitChannel Target channel where the form submissions will be sent. + * @param submitMessage A message presented to the user after they successfully + * submit the form. + * @param messageId ID of the message this form is attached to. A null + * value indicates that this form is not attached to any + * message. + * @param messageChannel Channel of the message this form is attached to. A null + * value indicates that this form is not attached. + * @param expiration Time after which this form will not accept further + * submissions. Value of + * {@link FormData#EXPIRATION_PERMANENT} indicates that + * this form will never expire. + * @param closed Closed state of this form. A closed form doesn't accept + * further submissions and has its components disabled. + * @param onetime Whether or not this form accepts one submission per + * user. + */ + public FormData(long id, List fields, String title, String submitChannel, String submitMessage, + String messageId, String messageChannel, long expiration, boolean closed, boolean onetime) { + this.id = id; + this.fields = Objects.requireNonNull(fields); + this.title = Objects.requireNonNull(title); + this.submitChannel = Objects.requireNonNull(submitChannel); + this.submitMessage = submitMessage; + this.messageId = messageId; + this.messageChannel = messageChannel; + this.expiration = expiration; + this.closed = closed; + this.onetime = onetime; + } + + /** + * Creates text components for use in the submission modal. + * + * @return Lsit of layout components for use in the submission modal. + */ + public LayoutComponent[] createComponents() { + LayoutComponent[] array = new LayoutComponent[fields.size()]; + for (int i = 0; i < array.length; i++) { + array[i] = ActionRow.of(fields.get(i).createTextInput("text" + i)); + } + return array; + } + + public long getExpiration() { + return expiration; + } + + public List getFields() { + return fields == null ? Collections.emptyList() : fields; + } + + public long getId() { + return id; + } + + public String getMessageChannel() { + return messageChannel; + } + + public String getMessageId() { + return messageId; + } + + public String getSubmitChannel() { + return submitChannel; + } + + public String getSubmitMessage() { + return submitMessage; + } + + public String getTitle() { + return title; + } + + /** + * Checks if the form can expire. + * + * @return true if this form has an expiration time. + */ + public boolean hasExpirationTime() { + return expiration > 0; + } + + /** + * Checks if the current form still accepts submissions. + * + * @return true, if the form has expired, false, if the form is still valid or + * can't expire. + */ + public boolean hasExpired() { + return hasExpirationTime() && expiration < System.currentTimeMillis(); + } + + public boolean isClosed() { + return closed; + } + + public boolean isOnetime() { + return onetime; + } + + @Override + public String toString() { + String prefix; + if (closed) { + prefix = "Closed"; + } else if (expiration == EXPIRATION_PERMANENT) { + prefix = "Permanent"; + } else if (expiration < System.currentTimeMillis()) { + prefix = "Expired"; + } else { + prefix = FormInteractionManager.DATE_FORMAT.format(new Date(expiration)) + " UTC"; + } + + return String.format("[%s] %s", prefix, title); + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java new file mode 100644 index 000000000..d3fb45929 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java @@ -0,0 +1,95 @@ +package net.discordjug.javabot.systems.staff_commands.forms.model; + +import net.dv8tion.jda.api.interactions.components.text.TextInput; +import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; + +/** + * Represents a form field. + */ +public class FormField { + private final String label; + private final int max; + private final int min; + private final String placeholder; + private final boolean required; + private final String style; + private final String value; + + /** + * Main constructor. + * + * @param label text field lable. + * @param max maximum characters allowed. + * @param min minimum characters allowed. + * @param placeholder text field placeholder. + * @param required whether or not this field is required. + * @param style text field style. One of {@link TextInputStyle} values. + * Case insensitive. + * @param value initial value of this text field. + */ + public FormField(String label, int max, int min, String placeholder, boolean required, String style, String value) { + this.label = label; + this.max = max; + this.min = min; + this.placeholder = placeholder; + this.required = required; + this.style = style; + this.value = value; + } + + /** + * Create a standalone text input from this field. + * + * @param id ID of this text input. + * @return text input ready to use in a modal. + */ + public TextInput createTextInput(String id) { + return TextInput.create(id, getLabel(), getStyle()).setRequiredRange(getMin(), getMax()) + .setPlaceholder(getPlaceholder()).setRequired(isRequired()).setValue(getValue()).build(); + } + + public String getLabel() { + return label; + } + + public int getMax() { + return max <= 0 ? 64 : max; + } + + public int getMin() { + return Math.max(0, min); + } + + public String getPlaceholder() { + return placeholder; + } + + /** + * Get a parsed style of this field's text input. + * + * @return one of {@link TextInputStyle} values. Defaults to + * {@link TextInputStyle#SHORT} if the stored value is invalid. + */ + public TextInputStyle getStyle() { + try { + return style == null ? TextInputStyle.SHORT : TextInputStyle.valueOf(style.toUpperCase()); + } catch (IllegalArgumentException e) { + return TextInputStyle.SHORT; + } + } + + public String getValue() { + return value; + } + + public boolean isRequired() { + return required; + } + + @Override + public String toString() { + return "FormField [label=" + label + ", max=" + max + ", min=" + min + ", placeholder=" + placeholder + + ", required=" + required + ", style=" + style + ", value=" + value + "]"; + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java new file mode 100644 index 000000000..2d515d687 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java @@ -0,0 +1,44 @@ +package net.discordjug.javabot.systems.staff_commands.forms.model; + +import java.util.Objects; + +/** + * Represents an user who submitted a form. + */ +public class FormUser { + private final long id; + private final String username; + + /** + * The main constructor. + * + * @param id user's id + * @param username user's username + */ + public FormUser(long id, String username) { + this.id = id; + this.username = username; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + FormUser other = (FormUser) obj; + return id == other.id && Objects.equals(username, other.username); + } + + public long getId() { + return id; + } + + public String getUsername() { + return username; + } + + @Override + public int hashCode() { + return Objects.hash(id, username); + } + +} diff --git a/src/main/resources/database/migrations/09-08-2025_forms.sql b/src/main/resources/database/migrations/09-08-2025_forms.sql new file mode 100644 index 000000000..f026b0e77 --- /dev/null +++ b/src/main/resources/database/migrations/09-08-2025_forms.sql @@ -0,0 +1,23 @@ +CREATE TABLE forms ( + form_id BIGINT NOT NULL, + form_data VARCHAR NOT NULL, + title VARCHAR NOT NULL, + submit_message VARCHAR, + submit_channel VARCHAR NOT NULL, + message_id VARCHAR, + message_channel VARCHAR, + expiration BIGINT NOT NULL, + closed BOOLEAN NOT NULL DEFAULT FALSE, + onetime BOOLEAN NOT NULL, + PRIMARY KEY (form_id) +); + +CREATE TABLE form_submissions ( + "timestamp" BIGINT NOT NULL, + user_id VARCHAR NOT NULL, + form_id BIGINT NOT NULL, + user_name VARCHAR NOT NULL, + PRIMARY KEY ("timestamp") +); + +CREATE INDEX FORM_SUBMISSIONS_USER_ID_IDX ON form_submissions (user_id,form_id); From bdf0b1af2d73fd6c0ef7966de2f939fbd26cd455 Mon Sep 17 00:00:00 2001 From: Defective Date: Wed, 17 Sep 2025 15:58:17 +0200 Subject: [PATCH 029/168] Store form fields in a separate table --- .../commands/AddFieldFormSubcommand.java | 4 +- .../forms/dao/FormsRepository.java | 78 +++++++------------ .../staff_commands/forms/model/FormField.java | 30 ++++--- .../database/migrations/09-08-2025_forms.sql | 21 ++++- 4 files changed, 68 insertions(+), 65 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java index e29885c56..8760d5f2d 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java @@ -66,7 +66,7 @@ public void execute(SlashCommandInteractionEvent event) { return; } - formsRepo.addField(form, createFormFieldFromEvent(event), index); + formsRepo.addField(form, createFormFieldFromEvent(event)); event.getHook().sendMessage("Added a new field to the form.").queue(); } @@ -98,6 +98,6 @@ private static FormField createFormFieldFromEvent(SlashCommandInteractionEvent e }); String value = e.getOption("value", OptionMapping::getAsString); - return new FormField(label, max, min, placeholder, required, style.name(), value); + return new FormField(label, max, min, placeholder, required, style, value, 0); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java index 1241c8dc7..8094fad5c 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -4,7 +4,6 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -18,10 +17,6 @@ import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Repository; -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.JsonParser; - import lombok.NonNull; import lombok.RequiredArgsConstructor; import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; @@ -30,6 +25,7 @@ import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; /** * Dao class that represents the FORMS database. @@ -37,7 +33,6 @@ @RequiredArgsConstructor @Repository public class FormsRepository { - private final Gson gson; private final JdbcTemplate jdbcTemplate; /** @@ -45,17 +40,13 @@ public class FormsRepository { * * @param form form to add field to * @param field field to add - * @param index insertion index, or -1 to append */ - public void addField(FormData form, FormField field, int index) { - Objects.requireNonNull(field); - List fields = new ArrayList<>(form.getFields()); - if (index != -1) { - fields.add(index, field); - } else { - fields.add(field); - } - updateFormData(form, fields); + public void addField(FormData form, FormField field) { + jdbcTemplate.update( + "INSERT INTO FORM_FIELDS (FORM_ID, LABEL, MIN, MAX, PLACEHOLDER, REQUIRED, \"style\", INITIAL) " + + "VALUES(?, ?, ?, ?, ?, ?, ?, ?)", + form.getId(), field.getLabel(), field.getMin(), field.getMax(), field.getPlaceholder(), + field.isRequired(), field.getStyle().name(), field.getValue()); } /** @@ -133,7 +124,7 @@ public void detachForm(FormData form) { * @return A list of forms */ public List getAllForms() { - return jdbcTemplate.query("select * from `forms`", (rs, rowNum) -> read(rs)); + return jdbcTemplate.query("select * from `forms`", (rs, rowNum) -> read(rs, readFormFields(rowNum))); } /** @@ -147,7 +138,7 @@ public List getAllForms(boolean closed) { PreparedStatement statement = con.prepareStatement("select * from `forms` where `closed` = ?"); statement.setBoolean(1, closed); return statement; - }, (rs, rowNum) -> read(rs)); + }, (rs, rowNum) -> read(rs, readFormFields(rowNum))); } /** @@ -176,7 +167,7 @@ public Map getAllSubmissions(FormData form) { public Optional getForm(long formId) { try { return Optional.of(jdbcTemplate.queryForObject("select * from `forms` where `form_id` = ?", - (RowMapper) (rs, rowNum) -> read(rs), formId)); + (RowMapper) (rs, rowNum) -> read(rs, readFormFields(formId)), formId)); } catch (EmptyResultDataAccessException e) { return Optional.empty(); } @@ -224,16 +215,14 @@ public void insertForm(@NonNull FormData data) { @Override public PreparedStatement createPreparedStatement(Connection con) throws SQLException { PreparedStatement statement = con.prepareStatement( - "merge into `forms` (form_id, form_data, title, submit_message, submit_channel, message_id, message_channel, expiration, onetime) values (?, ?, ?, ?, ?, ?, ? ,?, ?)"); - statement.setLong(1, data.getId()); - statement.setString(2, gson.toJson(data.getFields())); - statement.setString(3, data.getTitle()); - statement.setString(4, data.getSubmitMessage()); - statement.setString(5, data.getSubmitChannel()); - statement.setString(6, data.getMessageId()); - statement.setString(7, data.getMessageChannel()); - statement.setLong(8, data.getExpiration()); - statement.setBoolean(9, data.isOnetime()); + "insert into `forms` (title, submit_message, submit_channel, message_id, message_channel, expiration, onetime) values (?, ?, ?, ?, ?, ?, ?)"); + statement.setString(1, data.getTitle()); + statement.setString(2, data.getSubmitMessage()); + statement.setString(3, data.getSubmitChannel()); + statement.setString(4, data.getMessageId()); + statement.setString(5, data.getMessageChannel()); + statement.setLong(6, data.getExpiration()); + statement.setBoolean(7, data.isOnetime()); return statement; } }); @@ -266,10 +255,9 @@ public void logSubmission(User user, FormData form) { * @param index index of the field to remove */ public void removeField(FormData form, int index) { - List fields = new ArrayList<>(form.getFields()); + List fields = form.getFields(); if (index < 0 || index >= fields.size()) return; - fields.remove(index); - updateFormData(form, fields); + jdbcTemplate.update("delete from `form_fields` where `id` = ?", fields.get(index).getId()); } /** @@ -302,26 +290,20 @@ public void updateForm(FormData newData) { }); } - private FormData read(ResultSet rs) throws SQLException { - List fields = new ArrayList<>(); - for (JsonElement element : JsonParser.parseString(rs.getString("form_data")).getAsJsonArray()) { - fields.add(gson.fromJson(element, FormField.class)); - } + private List readFormFields(long formId) { + return jdbcTemplate.query("select * from `form_fields` where `form_id` = ?", (rs, rowNum) -> readField(rs), + formId); + } + + private static FormData read(ResultSet rs, List fields) throws SQLException { return new FormData(rs.getLong("form_id"), fields, rs.getString("title"), rs.getString("submit_channel"), rs.getString("submit_message"), rs.getString("message_id"), rs.getString("message_channel"), rs.getLong("expiration"), rs.getBoolean("closed"), rs.getBoolean("onetime")); } - private void updateFormData(FormData form, List fields) { - Objects.requireNonNull(form); - Objects.requireNonNull(fields); - String json = gson.toJson(fields); - jdbcTemplate.update(con -> { - PreparedStatement statement = con - .prepareStatement("update `forms` set `form_data` = ? where `form_id` = ?"); - statement.setString(1, json); - statement.setLong(2, form.getId()); - return statement; - }); + private static FormField readField(ResultSet rs) throws SQLException { + return new FormField(rs.getString("label"), rs.getInt("max"), rs.getInt("min"), rs.getString("placeholder"), + rs.getBoolean("required"), TextInputStyle.valueOf(rs.getString("style").toUpperCase()), + rs.getString("initial"), rs.getInt("id")); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java index d3fb45929..666a22c1b 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java @@ -7,12 +7,13 @@ * Represents a form field. */ public class FormField { + private final long id; private final String label; private final int max; private final int min; private final String placeholder; private final boolean required; - private final String style; + private final TextInputStyle style; private final String value; /** @@ -26,8 +27,11 @@ public class FormField { * @param style text field style. One of {@link TextInputStyle} values. * Case insensitive. * @param value initial value of this text field. + * @param id unique ID of this field. */ - public FormField(String label, int max, int min, String placeholder, boolean required, String style, String value) { + public FormField(String label, int max, int min, String placeholder, boolean required, TextInputStyle style, + String value, long id) { + this.id = id; this.label = label; this.max = max; this.min = min; @@ -48,6 +52,15 @@ public TextInput createTextInput(String id) { .setPlaceholder(getPlaceholder()).setRequired(isRequired()).setValue(getValue()).build(); } + /** + * Get this field's unique ID. + * + * @return unique ID of the field. + */ + public long getId() { + return id; + } + public String getLabel() { return label; } @@ -65,17 +78,12 @@ public String getPlaceholder() { } /** - * Get a parsed style of this field's text input. - * - * @return one of {@link TextInputStyle} values. Defaults to - * {@link TextInputStyle#SHORT} if the stored value is invalid. + * Get the style of this field's text input. + * + * @return one of {@link TextInputStyle} values. */ public TextInputStyle getStyle() { - try { - return style == null ? TextInputStyle.SHORT : TextInputStyle.valueOf(style.toUpperCase()); - } catch (IllegalArgumentException e) { - return TextInputStyle.SHORT; - } + return style; } public String getValue() { diff --git a/src/main/resources/database/migrations/09-08-2025_forms.sql b/src/main/resources/database/migrations/09-08-2025_forms.sql index f026b0e77..3e09fe990 100644 --- a/src/main/resources/database/migrations/09-08-2025_forms.sql +++ b/src/main/resources/database/migrations/09-08-2025_forms.sql @@ -1,17 +1,30 @@ CREATE TABLE forms ( - form_id BIGINT NOT NULL, - form_data VARCHAR NOT NULL, + form_id BIGINT NOT NULL AUTO_INCREMENT, title VARCHAR NOT NULL, submit_message VARCHAR, submit_channel VARCHAR NOT NULL, message_id VARCHAR, message_channel VARCHAR, - expiration BIGINT NOT NULL, + expiration BIGINT NOT NULL DEFAULT -1, closed BOOLEAN NOT NULL DEFAULT FALSE, - onetime BOOLEAN NOT NULL, + onetime BOOLEAN NOT NULL DEFAULT FALSE, PRIMARY KEY (form_id) ); +CREATE TABLE FORM_FIELDS ( + ID BIGINT NOT NULL AUTO_INCREMENT, + FORM_ID BIGINT NOT NULL, + LABEL CHARACTER VARYING NOT NULL, + MIN INTEGER DEFAULT 0 NOT NULL, + MAX INTEGER DEFAULT 16 NOT NULL, + PLACEHOLDER CHARACTER VARYING, + REQUIRED BOOLEAN DEFAULT FALSE NOT NULL, + "style" ENUM('SHORT', 'PARAGRAPH') DEFAULT 'SHORT' NOT NULL, + INITIAL CHARACTER VARYING, + PRIMARY KEY (ID), + FOREIGN KEY (FORM_ID) REFERENCES FORMS(FORM_ID) ON DELETE CASCADE ON UPDATE RESTRICT +); + CREATE TABLE form_submissions ( "timestamp" BIGINT NOT NULL, user_id VARCHAR NOT NULL, From b62574d58a428a3e4b9c5d2874e1d4a2630b5cb4 Mon Sep 17 00:00:00 2001 From: Defective Date: Wed, 17 Sep 2025 16:00:52 +0200 Subject: [PATCH 030/168] Use default values for all `NOT NULL` fields --- src/main/resources/database/migrations/09-08-2025_forms.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/database/migrations/09-08-2025_forms.sql b/src/main/resources/database/migrations/09-08-2025_forms.sql index 3e09fe990..05a90c2b4 100644 --- a/src/main/resources/database/migrations/09-08-2025_forms.sql +++ b/src/main/resources/database/migrations/09-08-2025_forms.sql @@ -1,10 +1,10 @@ CREATE TABLE forms ( form_id BIGINT NOT NULL AUTO_INCREMENT, title VARCHAR NOT NULL, - submit_message VARCHAR, + submit_message VARCHAR DEFAULT NULL, submit_channel VARCHAR NOT NULL, - message_id VARCHAR, - message_channel VARCHAR, + message_id VARCHAR DEFAULT NULL, + message_channel VARCHAR DEFAULT NULL, expiration BIGINT NOT NULL DEFAULT -1, closed BOOLEAN NOT NULL DEFAULT FALSE, onetime BOOLEAN NOT NULL DEFAULT FALSE, From 2c971722cbade249ba256b17ca4dfdbd14dfb6e6 Mon Sep 17 00:00:00 2001 From: Defective Date: Wed, 17 Sep 2025 16:21:21 +0200 Subject: [PATCH 031/168] Store form's message channel and id as longs instead of Strings --- .../forms/FormInteractionManager.java | 12 ++++----- .../forms/commands/AttachFormSubcommand.java | 2 +- .../forms/commands/CreateFormSubcommand.java | 2 +- .../forms/commands/DeleteFormSubcommand.java | 3 ++- .../forms/commands/DetachFormSubcommand.java | 7 ++--- .../forms/commands/DetailsFormSubcommand.java | 7 +++-- .../forms/commands/ModifyFormSubcommand.java | 6 ++--- .../commands/RemoveFieldFormSubcommand.java | 2 +- .../forms/dao/FormsRepository.java | 18 ++++++++----- .../staff_commands/forms/model/FormData.java | 27 +++++++++++-------- .../database/migrations/09-08-2025_forms.sql | 6 ++--- 11 files changed, 51 insertions(+), 41 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java index 32395d3da..dccbb5264 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java @@ -76,9 +76,9 @@ public class FormInteractionManager implements ButtonHandler, ModalHandler { public void closeForm(Guild guild, FormData form) { formsRepo.closeForm(form); - if (form.getMessageChannel() != null && form.getMessageId() != null) { - TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel()); - formChannel.retrieveMessageById(form.getMessageId()).queue(msg -> { + if (form.isAttached()) { + TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel().get()); + formChannel.retrieveMessageById(form.getMessageId().get()).queue(msg -> { mapFormMessageButtons(msg, btn -> { String cptId = btn.getId(); String[] split = ComponentIdBuilder.split(cptId); @@ -190,9 +190,9 @@ public void mapFormMessageButtons(Message msg, Function mapper) public void reopenForm(Guild guild, FormData form) { formsRepo.reopenForm(form); - if (form.getMessageChannel() != null && form.getMessageId() != null) { - TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel()); - formChannel.retrieveMessageById(form.getMessageId()).queue(msg -> { + if (form.isAttached()) { + TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel().get()); + formChannel.retrieveMessageById(form.getMessageId().get()).queue(msg -> { mapFormMessageButtons(msg, btn -> { String cptId = btn.getId(); String[] split = ComponentIdBuilder.split(cptId); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java index 0c31e0ed5..abc9ef7b6 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java @@ -63,7 +63,7 @@ public void execute(SlashCommandInteractionEvent event) { } FormData form = formOpt.get(); - if (form.getMessageChannel() != null && form.getMessageId() != null) { + if (form.isAttached()) { event.getHook() .sendMessage("The form seems to already be attached to a message. Detach it before continuing.") .queue(); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java index aab91e071..7060084d1 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java @@ -52,7 +52,7 @@ public void execute(SlashCommandInteractionEvent event) { long formId = System.currentTimeMillis(); FormData form = new FormData(formId, List.of(), event.getOption("title", OptionMapping::getAsString), - event.getOption("submit-channel", OptionMapping::getAsChannel).getId(), + event.getOption("submit-channel", OptionMapping::getAsChannel).getIdLong(), event.getOption("submit-message", null, OptionMapping::getAsString), null, null, expiration, false, event.getOption("onetime", false, OptionMapping::getAsBoolean)); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java index a368ab5b4..feecf00f9 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java @@ -48,8 +48,9 @@ public void execute(SlashCommandInteractionEvent event) { FormData form = formOpt.get(); formsRepo.deleteForm(form); - if (form.getMessageChannel() != null && form.getMessageId() != null) { + if (form.isAttached()) { DetachFormSubcommand.detachFromMessage(form, event.getGuild()); + // TODO send a warning } event.getHook().sendMessage("Form deleted!").queue(); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java index b40446972..b3b8cac0d 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java @@ -54,7 +54,7 @@ public void execute(SlashCommandInteractionEvent event) { } FormData form = formOpt.get(); - if (form.getMessageChannel() == null && form.getMessageId() == null) { + if (!form.isAttached()) { event.getHook().sendMessage("This form doesn't seem to be attached to a message").queue(); return; } @@ -80,8 +80,9 @@ public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCo * @param guild guild this form is contained in */ public static void detachFromMessage(FormData form, Guild guild) { - TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel()); - formChannel.retrieveMessageById(form.getMessageId()).queue(msg -> { + if(!form.isAttached()) return; + TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel().get()); + formChannel.retrieveMessageById(form.getMessageId().get()).queue(msg -> { List components = msg.getActionRows().stream().map(row -> { ItemComponent[] cpts = row.getComponents().stream().filter(cpt -> { if (cpt instanceof Button btn) { diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java index 008381f56..5cc7d283b 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java @@ -80,11 +80,10 @@ private EmbedBuilder createFormDetailsEmbed(FormData form, Guild guild) { addCodeblockField(builder, "State", form.isClosed() ? "Closed" : form.hasExpired() ? "Expired" : "Open", false); builder.addField("Attached in", - form.getMessageChannel() == null ? "*Not attached*" : "<#" + form.getMessageChannel() + ">", true); + form.isAttached() ? "<#" + form.getMessageChannel().get() + ">" : "*Not attached*", true); builder.addField("Attached to", - form.getMessageChannel() == null || form.getMessageId() == null ? "*Not attached*" - : String.format("[Link](https://discord.com/channels/%s/%s/%s)", guild.getId(), - form.getMessageChannel(), form.getMessageId()), + form.isAttached() ? String.format("[Link](https://discord.com/channels/%s/%s/%s)", guild.getId(), + form.getMessageChannel().get(), form.getMessageId().get()) : "*Not attached*", true); builder.addField("Submissions channel", "<#" + form.getSubmitChannel() + ">", true); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java index da4dd39a9..90c80a013 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java @@ -56,8 +56,7 @@ public void execute(SlashCommandInteractionEvent event) { FormData oldForm = formOpt.get(); String title = event.getOption("title", oldForm.getTitle(), OptionMapping::getAsString); - String submitChannel = event.getOption("submit-channel", oldForm.getSubmitChannel(), - OptionMapping::getAsString); + long submitChannel = event.getOption("submit-channel", oldForm.getSubmitChannel(), OptionMapping::getAsLong); String submitMessage = event.getOption("submit-message", oldForm.getSubmitMessage(), OptionMapping::getAsString); long expiration; @@ -72,7 +71,8 @@ public void execute(SlashCommandInteractionEvent event) { boolean onetime = event.getOption("onetime", oldForm.isOnetime(), OptionMapping::getAsBoolean); FormData newForm = new FormData(oldForm.getId(), oldForm.getFields(), title, submitChannel, submitMessage, - oldForm.getMessageId(), oldForm.getMessageChannel(), expiration, oldForm.isClosed(), onetime); + oldForm.getMessageId().orElse(null), oldForm.getMessageChannel().orElse(null), expiration, + oldForm.isClosed(), onetime); formsRepo.updateForm(newForm); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java index bcbd4dc1b..6c190a748 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java @@ -52,7 +52,7 @@ public void execute(SlashCommandInteractionEvent event) { return; } - if (form.getMessageChannel() != null && form.getMessageId() != null && form.getFields().size() <= 1) { + if (form.isAttached() && form.getFields().size() <= 1) { event.getHook().sendMessage( "Can't remove the last field from an attached form. Detach the form before removing the field") .queue(); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java index 8094fad5c..684dc148a 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -218,9 +218,9 @@ public PreparedStatement createPreparedStatement(Connection con) throws SQLExcep "insert into `forms` (title, submit_message, submit_channel, message_id, message_channel, expiration, onetime) values (?, ?, ?, ?, ?, ?, ?)"); statement.setString(1, data.getTitle()); statement.setString(2, data.getSubmitMessage()); - statement.setString(3, data.getSubmitChannel()); - statement.setString(4, data.getMessageId()); - statement.setString(5, data.getMessageChannel()); + statement.setLong(3, data.getSubmitChannel()); + statement.setObject(4, data.getMessageId().orElse(null)); + statement.setObject(5, data.getMessageChannel().orElse(null)); statement.setLong(6, data.getExpiration()); statement.setBoolean(7, data.isOnetime()); return statement; @@ -281,7 +281,7 @@ public void updateForm(FormData newData) { PreparedStatement statement = con.prepareStatement( "update `forms` set `title` = ?, `submit_channel` = ?, `submit_message` = ?, `expiration` = ?, `onetime` = ? where `form_id` = ?"); statement.setString(1, newData.getTitle()); - statement.setString(2, newData.getSubmitChannel()); + statement.setLong(2, newData.getSubmitChannel()); statement.setString(3, newData.getSubmitMessage()); statement.setLong(4, newData.getExpiration()); statement.setBoolean(5, newData.isOnetime()); @@ -296,9 +296,13 @@ private List readFormFields(long formId) { } private static FormData read(ResultSet rs, List fields) throws SQLException { - return new FormData(rs.getLong("form_id"), fields, rs.getString("title"), rs.getString("submit_channel"), - rs.getString("submit_message"), rs.getString("message_id"), rs.getString("message_channel"), - rs.getLong("expiration"), rs.getBoolean("closed"), rs.getBoolean("onetime")); + Long messageId = rs.getLong("message_id"); + if (rs.wasNull()) messageId = null; + Long messageChannel = rs.getLong("message_channel"); + if (rs.wasNull()) messageChannel = null; + return new FormData(rs.getLong("form_id"), fields, rs.getString("title"), rs.getLong("submit_channel"), + rs.getString("submit_message"), messageId, messageChannel, rs.getLong("expiration"), + rs.getBoolean("closed"), rs.getBoolean("onetime")); } private static FormField readField(ResultSet rs) throws SQLException { diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java index 33472d773..933ab0a9b 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java @@ -4,6 +4,7 @@ import java.util.Date; import java.util.List; import java.util.Objects; +import java.util.Optional; import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; import net.dv8tion.jda.api.interactions.components.ActionRow; @@ -24,10 +25,10 @@ public class FormData { private final long expiration; private final List fields; private final long id; - private final String messageId; - private final String messageChannel; + private final Long messageId; + private final Long messageChannel; private final boolean onetime; - private final String submitChannel; + private final long submitChannel; private final String submitMessage; private final String title; @@ -56,12 +57,12 @@ public class FormData { * @param onetime Whether or not this form accepts one submission per * user. */ - public FormData(long id, List fields, String title, String submitChannel, String submitMessage, - String messageId, String messageChannel, long expiration, boolean closed, boolean onetime) { + public FormData(long id, List fields, String title, long submitChannel, String submitMessage, + Long messageId, Long messageChannel, long expiration, boolean closed, boolean onetime) { this.id = id; this.fields = Objects.requireNonNull(fields); this.title = Objects.requireNonNull(title); - this.submitChannel = Objects.requireNonNull(submitChannel); + this.submitChannel = submitChannel; this.submitMessage = submitMessage; this.messageId = messageId; this.messageChannel = messageChannel; @@ -70,6 +71,10 @@ public FormData(long id, List fields, String title, String submitChan this.onetime = onetime; } + public boolean isAttached() { + return messageChannel != null && messageId != null; + } + /** * Creates text components for use in the submission modal. * @@ -95,15 +100,15 @@ public long getId() { return id; } - public String getMessageChannel() { - return messageChannel; + public Optional getMessageChannel() { + return Optional.ofNullable(messageChannel); } - public String getMessageId() { - return messageId; + public Optional getMessageId() { + return Optional.ofNullable(messageId); } - public String getSubmitChannel() { + public long getSubmitChannel() { return submitChannel; } diff --git a/src/main/resources/database/migrations/09-08-2025_forms.sql b/src/main/resources/database/migrations/09-08-2025_forms.sql index 05a90c2b4..eb4359a5c 100644 --- a/src/main/resources/database/migrations/09-08-2025_forms.sql +++ b/src/main/resources/database/migrations/09-08-2025_forms.sql @@ -2,9 +2,9 @@ CREATE TABLE forms ( form_id BIGINT NOT NULL AUTO_INCREMENT, title VARCHAR NOT NULL, submit_message VARCHAR DEFAULT NULL, - submit_channel VARCHAR NOT NULL, - message_id VARCHAR DEFAULT NULL, - message_channel VARCHAR DEFAULT NULL, + submit_channel BIGINT NOT NULL, + message_id BIGINT DEFAULT NULL, + message_channel BIGINT DEFAULT NULL, expiration BIGINT NOT NULL DEFAULT -1, closed BOOLEAN NOT NULL DEFAULT FALSE, onetime BOOLEAN NOT NULL DEFAULT FALSE, From 7db911a59f508696982cfde757dc1be06ad2d54c Mon Sep 17 00:00:00 2001 From: Defective Date: Wed, 17 Sep 2025 16:45:37 +0200 Subject: [PATCH 032/168] Changed submissions table structure to use unique ids --- .../forms/FormInteractionManager.java | 5 +-- .../SubmissionsDeleteFormSubcommand.java | 33 +++++-------------- .../forms/dao/FormsRepository.java | 30 ++++++----------- .../database/migrations/09-08-2025_forms.sql | 14 ++++---- 4 files changed, 29 insertions(+), 53 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java index dccbb5264..cd59b794e 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java @@ -149,8 +149,9 @@ public void handleModal(ModalInteractionEvent event, List values) return; } - channel.sendMessageEmbeds(createSubmissionEmbed(form, values, event.getMember())).queue(); - formsRepo.logSubmission(event.getUser(), form); + channel.sendMessageEmbeds(createSubmissionEmbed(form, values, event.getMember())).queue(msg -> { + formsRepo.logSubmission(event.getUser(), form, msg); + }); event.getHook() .sendMessage( diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java index c0334b264..c9082cb6f 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java @@ -4,6 +4,7 @@ import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.interactions.AutoCompleteQuery; @@ -29,11 +30,9 @@ public class SubmissionsDeleteFormSubcommand extends Subcommand implements AutoC */ public SubmissionsDeleteFormSubcommand(FormsRepository formsRepo) { this.formsRepo = formsRepo; - setCommandData( - new SubcommandData("submissions-delete", "Deletes submissions of an user in the form").addOptions( - new OptionData(OptionType.INTEGER, "form-id", "The ID of a form to get submissions for", true, - true), - new OptionData(OptionType.STRING, "user-id", "User to delete submissions of", true, true))); + setCommandData(new SubcommandData("submissions-delete", "Deletes submissions of an user in the form") + .addOptions(new OptionData(OptionType.INTEGER, "form-id", "The ID of a form to get submissions for", + true, true), new OptionData(OptionType.USER, "user", "User to delete submissions of", true))); } @Override @@ -45,7 +44,7 @@ public void execute(SlashCommandInteractionEvent event) { return; } - String user = event.getOption("user-id", OptionMapping::getAsString); + User user = event.getOption("user", OptionMapping::getAsUser); FormData form = formOpt.get(); int count = formsRepo.deleteSubmissions(form, user); @@ -54,24 +53,8 @@ public void execute(SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { - switch (target.getName()) { - case "user-id" -> { - Long formId = event.getOption("form-id", OptionMapping::getAsLong); - if (formId != null) { - Optional form = formsRepo.getForm(formId); - if (form.isPresent()) { - event.replyChoices(formsRepo.getAllSubmissions(form.get()).keySet().stream() - .map(user -> new Choice(user.getUsername(), Long.toString(user.getId()))).toList()) - .queue(); - return; - } - } - event.replyChoices().queue(); - } - case "form-id" -> event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) - .queue(); - default -> {} - } + event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java index 684dc148a..914cbcf7c 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -80,17 +80,6 @@ public void closeForm(FormData form) { */ public void deleteForm(FormData form) { jdbcTemplate.update("delete from `forms` where `form_id` = ?", form.getId()); - deleteSubmissions(form); - } - - /** - * Deletes all submissions for this form. - * - * @param form form to delete submissions for. - */ - public void deleteSubmissions(FormData form) { - Objects.requireNonNull(form); - jdbcTemplate.update("delete from `form_submissions` where `form_id` = ?", form.getId()); } /** @@ -100,11 +89,11 @@ public void deleteSubmissions(FormData form) { * @param user user to delete submissions for * @return number of deleted submissions */ - public int deleteSubmissions(FormData form, String user) { + public int deleteSubmissions(FormData form, User user) { Objects.requireNonNull(form); Objects.requireNonNull(user); return jdbcTemplate.update("delete from `form_submissions` where `form_id` = ? and `user_id` = ?", form.getId(), - user); + user.getIdLong()); } /** @@ -197,7 +186,7 @@ public boolean hasSubmitted(User user, FormData form) { try { return jdbcTemplate.queryForObject( "select * from `form_submissions` where `user_id` = ? and `form_id` = ? limit 1", - (rs, rowNum) -> true, user.getId(), form.getId()); + (rs, rowNum) -> true, user.getIdLong(), form.getId()); } catch (EmptyResultDataAccessException e) { return false; } @@ -231,17 +220,18 @@ public PreparedStatement createPreparedStatement(Connection con) throws SQLExcep /** * Log an user form submission in database. * - * @param user user to log - * @param form form to log on + * @param user user to log + * @param form form to log on + * @param message message containing details about this user's submission */ - public void logSubmission(User user, FormData form) { + public void logSubmission(User user, FormData form, Message message) { Objects.requireNonNull(user); Objects.requireNonNull(form); jdbcTemplate.update(con -> { PreparedStatement statement = con.prepareStatement( - "merge into `form_submissions` (\"timestamp\", `user_id`, `form_id`, `user_name`) values (?, ?, ?, ?)"); - statement.setLong(1, System.currentTimeMillis()); - statement.setString(2, user.getId()); + "insert into `form_submissions` (`message_id`, `user_id`, `form_id`, `user_name`) values (?, ?, ?, ?)"); + statement.setLong(1, message.getIdLong()); + statement.setLong(2, user.getIdLong()); statement.setLong(3, form.getId()); statement.setString(4, user.getName()); return statement; diff --git a/src/main/resources/database/migrations/09-08-2025_forms.sql b/src/main/resources/database/migrations/09-08-2025_forms.sql index eb4359a5c..e3d1d55bb 100644 --- a/src/main/resources/database/migrations/09-08-2025_forms.sql +++ b/src/main/resources/database/migrations/09-08-2025_forms.sql @@ -25,12 +25,14 @@ CREATE TABLE FORM_FIELDS ( FOREIGN KEY (FORM_ID) REFERENCES FORMS(FORM_ID) ON DELETE CASCADE ON UPDATE RESTRICT ); -CREATE TABLE form_submissions ( - "timestamp" BIGINT NOT NULL, - user_id VARCHAR NOT NULL, - form_id BIGINT NOT NULL, - user_name VARCHAR NOT NULL, - PRIMARY KEY ("timestamp") +CREATE TABLE FORM_SUBMISSIONS ( + ID BIGINT NOT NULL AUTO_INCREMENT, + MESSAGE_ID BIGINT NOT NULL, + USER_ID BIGINT NOT NULL, + FORM_ID BIGINT NOT NULL, + USER_NAME VARCHAR NOT NULL, + PRIMARY KEY (ID), + FOREIGN KEY (FORM_ID) REFERENCES FORMS(FORM_ID) ON DELETE CASCADE ON UPDATE RESTRICT ); CREATE INDEX FORM_SUBMISSIONS_USER_ID_IDX ON form_submissions (user_id,form_id); From e2c6372c6ee05357fcdc3b5fde85674735513c33 Mon Sep 17 00:00:00 2001 From: Defective Date: Wed, 17 Sep 2025 16:46:20 +0200 Subject: [PATCH 033/168] Correct `FormsRepository` javadoc --- .../systems/staff_commands/forms/dao/FormsRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java index 914cbcf7c..a7718bf40 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -28,7 +28,7 @@ import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; /** - * Dao class that represents the FORMS database. + * Dao class that represents the FORMS table. */ @RequiredArgsConstructor @Repository From 7cb72ed783095eae8b0493a0931a8990809e251d Mon Sep 17 00:00:00 2001 From: Defective Date: Wed, 17 Sep 2025 16:51:07 +0200 Subject: [PATCH 034/168] Adjustments to the `FormsRepository` class --- .../forms/FormInteractionManager.java | 2 +- .../SubmissionsExportFormSubcommand.java | 2 +- .../forms/dao/FormsRepository.java | 40 ++++++++----------- 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java index cd59b794e..6c67f622a 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java @@ -150,7 +150,7 @@ public void handleModal(ModalInteractionEvent event, List values) } channel.sendMessageEmbeds(createSubmissionEmbed(form, values, event.getMember())).queue(msg -> { - formsRepo.logSubmission(event.getUser(), form, msg); + formsRepo.addSubmission(event.getUser(), form, msg); }); event.getHook() diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java index f28f2c716..e13cb4cc3 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java @@ -55,7 +55,7 @@ public void execute(SlashCommandInteractionEvent event) { } FormData form = formOpt.get(); - Map submissions = formsRepo.getAllSubmissions(form); + Map submissions = formsRepo.getSubmissionsCountPerUser(form); JsonObject root = new JsonObject(); JsonObject details = new JsonObject(); JsonArray users = new JsonArray(); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java index a7718bf40..e3ead8763 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -1,6 +1,5 @@ package net.discordjug.javabot.systems.staff_commands.forms.dao; -import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; @@ -13,7 +12,6 @@ import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.PreparedStatementCreator; import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Repository; @@ -136,13 +134,13 @@ public List getAllForms(boolean closed) { * @param form a form to get submissions for * @return a map of users and the number of their submissions */ - public Map getAllSubmissions(FormData form) { + public Map getSubmissionsCountPerUser(FormData form) { Objects.requireNonNull(form); List users = jdbcTemplate.query("select * from `form_submissions` where `form_id` = ?", (rs, rowNum) -> new FormUser(rs.getLong("user_id"), rs.getString("user_name")), form.getId()); Map map = new HashMap<>(); for (FormUser user : users) { - map.compute(user, (t, u) -> u == null ? 1 : u + 1); + map.merge(user, 1, Integer::sum); } return Collections.unmodifiableMap(map); } @@ -151,7 +149,7 @@ public Map getAllSubmissions(FormData form) { * Get a form for given ID. * * @param formId form ID to query - * @return optional containing the form, or empty if the form was not found. + * @return optional form */ public Optional getForm(long formId) { try { @@ -175,7 +173,7 @@ public int getTotalSubmissionsCount(FormData form) { } /** - * Checks if an user already submitted the form. + * Checks if a user already submitted the form. * * @param user user to check * @param form form to check on @@ -199,32 +197,28 @@ public boolean hasSubmitted(User user, FormData form) { */ public void insertForm(@NonNull FormData data) { Objects.requireNonNull(data); - jdbcTemplate.update(new PreparedStatementCreator() { - - @Override - public PreparedStatement createPreparedStatement(Connection con) throws SQLException { - PreparedStatement statement = con.prepareStatement( - "insert into `forms` (title, submit_message, submit_channel, message_id, message_channel, expiration, onetime) values (?, ?, ?, ?, ?, ?, ?)"); - statement.setString(1, data.getTitle()); - statement.setString(2, data.getSubmitMessage()); - statement.setLong(3, data.getSubmitChannel()); - statement.setObject(4, data.getMessageId().orElse(null)); - statement.setObject(5, data.getMessageChannel().orElse(null)); - statement.setLong(6, data.getExpiration()); - statement.setBoolean(7, data.isOnetime()); - return statement; - } + jdbcTemplate.update(con -> { + PreparedStatement statement = con.prepareStatement( + "insert into `forms` (title, submit_message, submit_channel, message_id, message_channel, expiration, onetime) values (?, ?, ?, ?, ?, ?, ?)"); + statement.setString(1, data.getTitle()); + statement.setString(2, data.getSubmitMessage()); + statement.setLong(3, data.getSubmitChannel()); + statement.setObject(4, data.getMessageId().orElse(null)); + statement.setObject(5, data.getMessageChannel().orElse(null)); + statement.setLong(6, data.getExpiration()); + statement.setBoolean(7, data.isOnetime()); + return statement; }); } /** - * Log an user form submission in database. + * Add a user form submission to the database. * * @param user user to log * @param form form to log on * @param message message containing details about this user's submission */ - public void logSubmission(User user, FormData form, Message message) { + public void addSubmission(User user, FormData form, Message message) { Objects.requireNonNull(user); Objects.requireNonNull(form); jdbcTemplate.update(con -> { From 61d7e3612a985e6f630f7002bbf7d3684cb3e1ff Mon Sep 17 00:00:00 2001 From: Defective Date: Wed, 17 Sep 2025 16:52:28 +0200 Subject: [PATCH 035/168] Fix javadoc spelling mistakes --- .../forms/commands/SubmissionsDeleteFormSubcommand.java | 2 +- .../systems/staff_commands/forms/dao/FormsRepository.java | 2 +- .../javabot/systems/staff_commands/forms/model/FormData.java | 2 +- .../javabot/systems/staff_commands/forms/model/FormUser.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java index c9082cb6f..7e2f4c756 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java @@ -30,7 +30,7 @@ public class SubmissionsDeleteFormSubcommand extends Subcommand implements AutoC */ public SubmissionsDeleteFormSubcommand(FormsRepository formsRepo) { this.formsRepo = formsRepo; - setCommandData(new SubcommandData("submissions-delete", "Deletes submissions of an user in the form") + setCommandData(new SubcommandData("submissions-delete", "Deletes submissions of a user in the form") .addOptions(new OptionData(OptionType.INTEGER, "form-id", "The ID of a form to get submissions for", true, true), new OptionData(OptionType.USER, "user", "User to delete submissions of", true))); } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java index e3ead8763..e0934bc01 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -129,7 +129,7 @@ public List getAllForms(boolean closed) { } /** - * Get all submissions of this form in an user -> count map. + * Get all submissions of this form in a user -> count map. * * @param form a form to get submissions for * @return a map of users and the number of their submissions diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java index 933ab0a9b..3a7c0aabe 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java @@ -78,7 +78,7 @@ public boolean isAttached() { /** * Creates text components for use in the submission modal. * - * @return Lsit of layout components for use in the submission modal. + * @return List of layout components for use in the submission modal. */ public LayoutComponent[] createComponents() { LayoutComponent[] array = new LayoutComponent[fields.size()]; diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java index 2d515d687..12ae3a920 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java @@ -3,7 +3,7 @@ import java.util.Objects; /** - * Represents an user who submitted a form. + * Represents a user who submitted a form. */ public class FormUser { private final long id; From 21d9a420e057a6ddb990abfb6ae427038106b143 Mon Sep 17 00:00:00 2001 From: Defective Date: Wed, 17 Sep 2025 17:01:09 +0200 Subject: [PATCH 036/168] Make the `FormField` into a record --- .../forms/FormInteractionManager.java | 2 +- .../commands/AddFieldFormSubcommand.java | 9 +- .../commands/RemoveFieldFormSubcommand.java | 4 +- .../forms/dao/FormsRepository.java | 6 +- .../staff_commands/forms/model/FormField.java | 82 +------------------ 5 files changed, 11 insertions(+), 92 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java index 6c67f622a..f5181d1d7 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java @@ -268,7 +268,7 @@ private static MessageEmbed createSubmissionEmbed(FormData form, List= form.getFields().size()) { - event.getHook().sendMessage("Field index out of bounds").queue(); - return; - } - formsRepo.addField(form, createFormFieldFromEvent(event)); event.getHook().sendMessage("Added a new field to the form.").queue(); } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java index 6c190a748..99bcd2928 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java @@ -61,7 +61,7 @@ public void execute(SlashCommandInteractionEvent event) { formsRepo.removeField(form, index); - event.getHook().sendMessage("Removed field `" + form.getFields().get(index).getLabel() + "` from the form.") + event.getHook().sendMessage("Removed field `" + form.getFields().get(index).label() + "` from the form.") .queue(); } @@ -79,7 +79,7 @@ public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCo List choices = new ArrayList<>(); List fields = form.get().getFields(); for (int i = 0; i < fields.size(); i++) { - choices.add(new Choice(fields.get(i).getLabel(), i)); + choices.add(new Choice(fields.get(i).label(), i)); } event.replyChoices(choices).queue(); return; diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java index e0934bc01..cfa697478 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -43,8 +43,8 @@ public void addField(FormData form, FormField field) { jdbcTemplate.update( "INSERT INTO FORM_FIELDS (FORM_ID, LABEL, MIN, MAX, PLACEHOLDER, REQUIRED, \"style\", INITIAL) " + "VALUES(?, ?, ?, ?, ?, ?, ?, ?)", - form.getId(), field.getLabel(), field.getMin(), field.getMax(), field.getPlaceholder(), - field.isRequired(), field.getStyle().name(), field.getValue()); + form.getId(), field.label(), field.min(), field.max(), field.placeholder(), field.required(), + field.style().name(), field.value()); } /** @@ -241,7 +241,7 @@ public void addSubmission(User user, FormData form, Message message) { public void removeField(FormData form, int index) { List fields = form.getFields(); if (index < 0 || index >= fields.size()) return; - jdbcTemplate.update("delete from `form_fields` where `id` = ?", fields.get(index).getId()); + jdbcTemplate.update("delete from `form_fields` where `id` = ?", fields.get(index).id()); } /** diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java index 666a22c1b..92e0fc915 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java @@ -6,40 +6,8 @@ /** * Represents a form field. */ -public class FormField { - private final long id; - private final String label; - private final int max; - private final int min; - private final String placeholder; - private final boolean required; - private final TextInputStyle style; - private final String value; - - /** - * Main constructor. - * - * @param label text field lable. - * @param max maximum characters allowed. - * @param min minimum characters allowed. - * @param placeholder text field placeholder. - * @param required whether or not this field is required. - * @param style text field style. One of {@link TextInputStyle} values. - * Case insensitive. - * @param value initial value of this text field. - * @param id unique ID of this field. - */ - public FormField(String label, int max, int min, String placeholder, boolean required, TextInputStyle style, - String value, long id) { - this.id = id; - this.label = label; - this.max = max; - this.min = min; - this.placeholder = placeholder; - this.required = required; - this.style = style; - this.value = value; - } +public record FormField(String label, int max, int min, String placeholder, boolean required, + TextInputStyle style, String value, long id) { /** * Create a standalone text input from this field. @@ -48,50 +16,8 @@ public FormField(String label, int max, int min, String placeholder, boolean req * @return text input ready to use in a modal. */ public TextInput createTextInput(String id) { - return TextInput.create(id, getLabel(), getStyle()).setRequiredRange(getMin(), getMax()) - .setPlaceholder(getPlaceholder()).setRequired(isRequired()).setValue(getValue()).build(); - } - - /** - * Get this field's unique ID. - * - * @return unique ID of the field. - */ - public long getId() { - return id; - } - - public String getLabel() { - return label; - } - - public int getMax() { - return max <= 0 ? 64 : max; - } - - public int getMin() { - return Math.max(0, min); - } - - public String getPlaceholder() { - return placeholder; - } - - /** - * Get the style of this field's text input. - * - * @return one of {@link TextInputStyle} values. - */ - public TextInputStyle getStyle() { - return style; - } - - public String getValue() { - return value; - } - - public boolean isRequired() { - return required; + return TextInput.create(id, label(), style()).setRequiredRange(min(), max()).setPlaceholder(placeholder()) + .setRequired(required()).setValue(value()).build(); } @Override From 3425fa0556defb2e6d33c995065ebf471ffafb8b Mon Sep 17 00:00:00 2001 From: Defective Date: Thu, 18 Sep 2025 12:24:29 +0200 Subject: [PATCH 037/168] Convert `FormData` into a record and use `Instants` for expiration Signed-off-by: Defective --- .../forms/FormInteractionManager.java | 46 ++++---- .../commands/AddFieldFormSubcommand.java | 4 +- .../forms/commands/AttachFormSubcommand.java | 8 +- .../forms/commands/CloseFormSubcommand.java | 4 +- .../forms/commands/CreateFormSubcommand.java | 16 ++- .../forms/commands/DeleteFormSubcommand.java | 2 +- .../forms/commands/DetachFormSubcommand.java | 4 +- .../forms/commands/DetailsFormSubcommand.java | 25 ++-- .../forms/commands/ModifyFormSubcommand.java | 32 +++--- .../commands/RemoveFieldFormSubcommand.java | 10 +- .../forms/commands/ReopenFormSubcommand.java | 4 +- .../forms/commands/ShowFormSubcommand.java | 4 +- .../SubmissionsDeleteFormSubcommand.java | 2 +- .../SubmissionsExportFormSubcommand.java | 4 +- .../forms/dao/FormsRepository.java | 58 +++++----- .../staff_commands/forms/model/FormData.java | 108 +++--------------- .../staff_commands/forms/model/FormField.java | 7 -- .../database/migrations/09-08-2025_forms.sql | 40 +++---- 18 files changed, 156 insertions(+), 222 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java index f5181d1d7..ec00186ce 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java @@ -83,7 +83,7 @@ public void closeForm(Guild guild, FormData form) { String cptId = btn.getId(); String[] split = ComponentIdBuilder.split(cptId); if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID) - && split[1].equals(Long.toString(form.getId()))) { + && split[1].equals(Long.toString(form.id()))) { return btn.asDisabled(); } return btn; @@ -103,13 +103,13 @@ public void handleButton(ButtonInteractionEvent event, Button button) { FormData form = formOpt.get(); if (!checkNotClosed(form)) { event.reply("This form is not accepting new submissions.").setEphemeral(true).queue(); - if (!form.isClosed()) { + if (!form.closed()) { closeForm(event.getGuild(), form); } return; } - if (form.isOnetime() && formsRepo.hasSubmitted(event.getUser(), form)) { + if (form.onetime() && formsRepo.hasSubmitted(event.getUser(), form)) { event.reply("You have already submitted this form").setEphemeral(true).queue(); return; } @@ -136,12 +136,12 @@ public void handleModal(ModalInteractionEvent event, List values) return; } - if (form.isOnetime() && formsRepo.hasSubmitted(event.getUser(), form)) { + if (form.onetime() && formsRepo.hasSubmitted(event.getUser(), form)) { event.getHook().sendMessage("You have already submitted this form").queue(); return; } - TextChannel channel = event.getGuild().getTextChannelById(form.getSubmitChannel()); + TextChannel channel = event.getGuild().getTextChannelById(form.submitChannel()); if (channel == null) { event.getHook() .sendMessage("We couldn't receive your submission due to an error. Please contact server staff.") @@ -154,8 +154,7 @@ public void handleModal(ModalInteractionEvent event, List values) }); event.getHook() - .sendMessage( - form.getSubmitMessage() == null ? "Your submission was received!" : form.getSubmitMessage()) + .sendMessage(form.submitMessage() == null ? "Your submission was received!" : form.submitMessage()) .queue(); } @@ -198,7 +197,7 @@ public void reopenForm(Guild guild, FormData form) { String cptId = btn.getId(); String[] split = ComponentIdBuilder.split(cptId); if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID) - && split[1].equals(Long.toString(form.getId()))) { + && split[1].equals(Long.toString(form.id()))) { return btn.asEnabled(); } return btn; @@ -214,7 +213,7 @@ public void reopenForm(Guild guild, FormData form) { * @return submission modal to be presented to the user. */ public static Modal createFormModal(FormData form) { - Modal modal = Modal.create(ComponentIdBuilder.build(FORM_COMPONENT_ID, form.getId()), form.getTitle()) + Modal modal = Modal.create(ComponentIdBuilder.build(FORM_COMPONENT_ID, form.id()), form.title()) .addComponents(form.createComponents()).build(); return modal; } @@ -226,32 +225,31 @@ public static Modal createFormModal(FormData form) { * @return an optional containing expiration time, * {@link FormData#EXPIRATION_PERMANENT} if none given, or an empty * optional if it's invalid. + * @throws IllegalArgumentException if the date doesn't follow the format. */ - public static Optional parseExpiration(SlashCommandInteractionEvent event) { + public static Optional parseExpiration(SlashCommandInteractionEvent event) + throws IllegalArgumentException { String expirationStr = event.getOption("expiration", null, OptionMapping::getAsString); - Optional expiration; + Optional expiration; if (expirationStr == null) { - expiration = Optional.of(FormData.EXPIRATION_PERMANENT); + expiration = Optional.empty(); } else { try { - expiration = Optional.of(FormInteractionManager.DATE_FORMAT.parse(expirationStr).getTime()); + expiration = Optional.of(FormInteractionManager.DATE_FORMAT.parse(expirationStr).toInstant()); } catch (ParseException e) { - event.getHook().sendMessage("Invalid date. You should follow the format `" - + FormInteractionManager.DATE_FORMAT_STRING + "`.").setEphemeral(true).queue(); - expiration = Optional.empty(); + throw new IllegalArgumentException("Invalid date. You should follow the format `" + + FormInteractionManager.DATE_FORMAT_STRING + "`."); } } - if (expiration.isPresent() && expiration.get() != FormData.EXPIRATION_PERMANENT - && expiration.get() < System.currentTimeMillis()) { - event.getHook().sendMessage("The expiration date shouldn't be in the past").setEphemeral(true).queue(); - return Optional.empty(); + if (expiration.isPresent() && expiration.get().isBefore(Instant.now())) { + throw new IllegalArgumentException("The expiration date shouldn't be in the past"); } return expiration; } private static boolean checkNotClosed(FormData data) { - if (data.isClosed() || data.hasExpired()) { + if (data.closed() || data.hasExpired()) { return false; } @@ -261,12 +259,12 @@ private static boolean checkNotClosed(FormData data) { private static MessageEmbed createSubmissionEmbed(FormData form, List values, Member author) { EmbedBuilder builder = new EmbedBuilder().setTitle("New form submission received") .setAuthor(author.getEffectiveName(), null, author.getEffectiveAvatarUrl()).setTimestamp(Instant.now()); - builder.addField("Sender", author.getAsMention(), true).addField("Title", form.getTitle(), true); + builder.addField("Sender", author.getAsMention(), true).addField("Title", form.title(), true); - int len = Math.min(values.size(), form.getFields().size()); + int len = Math.min(values.size(), form.fields().size()); for (int i = 0; i < len; i++) { ModalMapping mapping = values.get(i); - FormField field = form.getFields().get(i); + FormField field = form.fields().get(i); String value = mapping.getAsString(); builder.addField(field.label(), value == null ? "*Empty*" : "```\n" + value + "\n```", false); } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java index fef562da5..23a126b2c 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java @@ -54,7 +54,7 @@ public void execute(SlashCommandInteractionEvent event) { } FormData form = formOpt.get(); - if (form.getFields().size() >= 5) { + if (form.fields().size() >= 5) { event.getHook().sendMessage("Can't add more than 5 components to a form").queue(); return; } @@ -67,7 +67,7 @@ public void execute(SlashCommandInteractionEvent event) { public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { switch (target.getName()) { case "form-id" -> event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); case "style" -> event.replyChoices(Arrays.stream(TextInputStyle.values()).filter(t -> t != TextInputStyle.UNKNOWN) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java index abc9ef7b6..e42b5bdf9 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java @@ -70,7 +70,7 @@ public void execute(SlashCommandInteractionEvent event) { return; } - if (form.getFields().isEmpty()) { + if (form.fields().isEmpty()) { event.getHook().sendMessage("You can't attach a form with no fields.").queue(); return; } @@ -111,7 +111,7 @@ public void execute(SlashCommandInteractionEvent event) { public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { switch (target.getName()) { case "form-id" -> event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); case "button-style" -> event.replyChoices( Set.of(ButtonStyle.DANGER, ButtonStyle.PRIMARY, ButtonStyle.SECONDARY, ButtonStyle.SUCCESS).stream() @@ -125,9 +125,9 @@ private static void attachFormToMessage(Message message, String buttonLabel, But List rows = new ArrayList<>(message.getActionRows()); Button button = Button.of(style, - ComponentIdBuilder.build(FormInteractionManager.FORM_COMPONENT_ID, form.getId()), buttonLabel); + ComponentIdBuilder.build(FormInteractionManager.FORM_COMPONENT_ID, form.id()), buttonLabel); - if (form.isClosed() || form.hasExpired()) { + if (form.closed() || form.hasExpired()) { button = button.asDisabled(); } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java index 3c24e0079..e93400205 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java @@ -50,7 +50,7 @@ public void execute(SlashCommandInteractionEvent event) { } FormData form = formOpt.get(); - if (form.isClosed()) { + if (form.closed()) { event.reply("This form is already closed").setEphemeral(true).queue(); return; } @@ -65,7 +65,7 @@ public void execute(SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { event.replyChoices( - formsRepo.getAllForms(false).stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms(false).stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java index 7060084d1..5958f143b 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java @@ -1,5 +1,6 @@ package net.discordjug.javabot.systems.staff_commands.forms.commands; +import java.time.Instant; import java.util.List; import java.util.Optional; @@ -44,14 +45,17 @@ public void execute(SlashCommandInteractionEvent event) { event.deferReply().setEphemeral(true).queue(); String expirationStr = event.getOption("expiration", null, OptionMapping::getAsString); - Optional expirationOpt = FormInteractionManager.parseExpiration(event); + Optional expirationOpt; + try { + expirationOpt = FormInteractionManager.parseExpiration(event); + } catch (IllegalArgumentException e) { + event.getHook().sendMessage(e.getMessage()).queue(); + return; + } - if (expirationOpt.isEmpty()) return; + Instant expiration = expirationOpt.orElse(null); - long expiration = expirationOpt.get(); - - long formId = System.currentTimeMillis(); - FormData form = new FormData(formId, List.of(), event.getOption("title", OptionMapping::getAsString), + FormData form = new FormData(0, List.of(), event.getOption("title", OptionMapping::getAsString), event.getOption("submit-channel", OptionMapping::getAsChannel).getIdLong(), event.getOption("submit-message", null, OptionMapping::getAsString), null, null, expiration, false, event.getOption("onetime", false, OptionMapping::getAsBoolean)); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java index feecf00f9..4a9ba514d 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java @@ -59,7 +59,7 @@ public void execute(SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java index b3b8cac0d..6853f7bd0 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java @@ -68,7 +68,7 @@ public void execute(SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); } @@ -89,7 +89,7 @@ public static void detachFromMessage(FormData form, Guild guild) { String cptId = btn.getId(); String[] split = ComponentIdBuilder.split(cptId); if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID)) { - return !split[1].equals(Long.toString(form.getId())); + return !split[1].equals(Long.toString(form.id())); } } return true; diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java index 5cc7d283b..8a11df5fb 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java @@ -61,37 +61,40 @@ public void execute(SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) - .queue(); + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()).queue(); } private EmbedBuilder createFormDetailsEmbed(FormData form, Guild guild) { EmbedBuilder builder = new EmbedBuilder().setTitle("Form details"); - long id = form.getId(); + long id = form.id(); addCodeblockField(builder, "ID", id, true); builder.addField("Created at", String.format("", id / 1000L), true); String expiration; builder.addField("Expires at", - form.hasExpirationTime() ? String.format("", form.getExpiration() / 1000L) : "`Never`", true); + form.hasExpirationTime() ? String.format("", form.expiration().toEpochMilli() / 1000L) + : "`Never`", + true); - addCodeblockField(builder, "State", form.isClosed() ? "Closed" : form.hasExpired() ? "Expired" : "Open", false); + addCodeblockField(builder, "State", form.closed() ? "Closed" : form.hasExpired() ? "Expired" : "Open", false); builder.addField("Attached in", form.isAttached() ? "<#" + form.getMessageChannel().get() + ">" : "*Not attached*", true); builder.addField("Attached to", - form.isAttached() ? String.format("[Link](https://discord.com/channels/%s/%s/%s)", guild.getId(), - form.getMessageChannel().get(), form.getMessageId().get()) : "*Not attached*", + form.isAttached() + ? String.format("[Link](https://discord.com/channels/%s/%s/%s)", guild.getId(), + form.getMessageChannel().get(), form.getMessageId().get()) + : "*Not attached*", true); - builder.addField("Submissions channel", "<#" + form.getSubmitChannel() + ">", true); - builder.addField("Is one-time", form.isOnetime() ? ":white_check_mark:" : ":x:", true); + builder.addField("Submissions channel", "<#" + form.submitChannel() + ">", true); + builder.addField("Is one-time", form.onetime() ? ":white_check_mark:" : ":x:", true); addCodeblockField(builder, "Submission message", - form.getSubmitMessage() == null ? "Default" : form.getSubmitMessage(), true); + form.submitMessage() == null ? "Default" : form.submitMessage(), true); - addCodeblockField(builder, "Number of fields", form.getFields().size(), true); + addCodeblockField(builder, "Number of fields", form.fields().size(), true); addCodeblockField(builder, "Number of submissions", formsRepo.getTotalSubmissionsCount(form), true); return builder; diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java index 90c80a013..a9c937e5f 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java @@ -1,5 +1,6 @@ package net.discordjug.javabot.systems.staff_commands.forms.commands; +import java.time.Instant; import java.util.Optional; import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; @@ -55,24 +56,28 @@ public void execute(SlashCommandInteractionEvent event) { } FormData oldForm = formOpt.get(); - String title = event.getOption("title", oldForm.getTitle(), OptionMapping::getAsString); - long submitChannel = event.getOption("submit-channel", oldForm.getSubmitChannel(), OptionMapping::getAsLong); - String submitMessage = event.getOption("submit-message", oldForm.getSubmitMessage(), - OptionMapping::getAsString); - long expiration; + String title = event.getOption("title", oldForm.title(), OptionMapping::getAsString); + long submitChannel = event.getOption("submit-channel", oldForm.submitChannel(), OptionMapping::getAsLong); + String submitMessage = event.getOption("submit-message", oldForm.submitMessage(), OptionMapping::getAsString); + Instant expiration; if (event.getOption("expiration") == null) { - expiration = oldForm.getExpiration(); + expiration = oldForm.expiration(); } else { - Optional expirationOpt = FormInteractionManager.parseExpiration(event); - if (expirationOpt.isEmpty()) return; - expiration = expirationOpt.get(); + Optional expirationOpt; + try { + expirationOpt = FormInteractionManager.parseExpiration(event); + } catch (IllegalArgumentException e) { + event.getHook().sendMessage(e.getMessage()).queue(); + return; + } + expiration = expirationOpt.orElse(oldForm.expiration()); } - boolean onetime = event.getOption("onetime", oldForm.isOnetime(), OptionMapping::getAsBoolean); + boolean onetime = event.getOption("onetime", oldForm.onetime(), OptionMapping::getAsBoolean); - FormData newForm = new FormData(oldForm.getId(), oldForm.getFields(), title, submitChannel, submitMessage, + FormData newForm = new FormData(oldForm.id(), oldForm.fields(), title, submitChannel, submitMessage, oldForm.getMessageId().orElse(null), oldForm.getMessageChannel().orElse(null), expiration, - oldForm.isClosed(), onetime); + oldForm.closed(), onetime); formsRepo.updateForm(newForm); @@ -82,8 +87,7 @@ public void execute(SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) - .queue(); + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()).queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java index 99bcd2928..13d22b588 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java @@ -47,12 +47,12 @@ public void execute(SlashCommandInteractionEvent event) { return; } FormData form = formOpt.get(); - if (index < 0 || index >= form.getFields().size()) { + if (index < 0 || index >= form.fields().size()) { event.getHook().sendMessage("Field index out of bounds.").queue(); return; } - if (form.isAttached() && form.getFields().size() <= 1) { + if (form.isAttached() && form.fields().size() <= 1) { event.getHook().sendMessage( "Can't remove the last field from an attached form. Detach the form before removing the field") .queue(); @@ -61,7 +61,7 @@ public void execute(SlashCommandInteractionEvent event) { formsRepo.removeField(form, index); - event.getHook().sendMessage("Removed field `" + form.getFields().get(index).label() + "` from the form.") + event.getHook().sendMessage("Removed field `" + form.fields().get(index).label() + "` from the form.") .queue(); } @@ -69,7 +69,7 @@ public void execute(SlashCommandInteractionEvent event) { public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { switch (target.getName()) { case "form-id" -> event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); case "field" -> { Long formId = event.getOption("form-id", OptionMapping::getAsLong); @@ -77,7 +77,7 @@ public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCo Optional form = formsRepo.getForm(formId); if (form.isPresent()) { List choices = new ArrayList<>(); - List fields = form.get().getFields(); + List fields = form.get().fields(); for (int i = 0; i < fields.size(); i++) { choices.add(new Choice(fields.get(i).label(), i)); } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java index 3b130773b..92bb867da 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java @@ -50,7 +50,7 @@ public void execute(SlashCommandInteractionEvent event) { } FormData form = formOpt.get(); - if (!form.isClosed()) { + if (!form.closed()) { event.reply("This form is already opened").setEphemeral(true).queue(); return; } @@ -65,7 +65,7 @@ public void execute(SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { event.replyChoices( - formsRepo.getAllForms(true).stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms(true).stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java index c701576d1..10b004bc0 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java @@ -43,7 +43,7 @@ public void execute(SlashCommandInteractionEvent event) { return; } FormData form = formOpt.get(); - if (form.getFields().isEmpty()) { + if (form.fields().isEmpty()) { event.reply("You can't open a form with no fields").setEphemeral(true).queue(); return; } @@ -53,7 +53,7 @@ public void execute(SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java index 7e2f4c756..36f7ef470 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java @@ -54,7 +54,7 @@ public void execute(SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java index e13cb4cc3..83e385bd4 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java @@ -69,13 +69,13 @@ public void execute(SlashCommandInteractionEvent event) { root.add("users", users); root.add("details", details); event.getHook().sendFiles(FileUpload.fromData(gson.toJson(root).getBytes(StandardCharsets.UTF_8), - "submissions_" + form.getId() + ".json")).queue(); + "submissions_" + form.id() + ".json")).queue(); } @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java index cfa697478..e644475b8 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -3,6 +3,8 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -41,9 +43,9 @@ public class FormsRepository { */ public void addField(FormData form, FormField field) { jdbcTemplate.update( - "INSERT INTO FORM_FIELDS (FORM_ID, LABEL, MIN, MAX, PLACEHOLDER, REQUIRED, \"style\", INITIAL) " + "INSERT INTO form_fields (form_id, label, min, max, placeholder, \"required\", \"style\", initial) " + "VALUES(?, ?, ?, ?, ?, ?, ?, ?)", - form.getId(), field.label(), field.min(), field.max(), field.placeholder(), field.required(), + form.id(), field.label(), field.min(), field.max(), field.placeholder(), field.required(), field.style().name(), field.value()); } @@ -59,7 +61,7 @@ public void attachForm(FormData form, MessageChannel channel, Message message) { Objects.requireNonNull(channel); Objects.requireNonNull(message); jdbcTemplate.update("update `forms` set `message_id` = ?, `message_channel` = ? where `form_id` = ?", - message.getId(), channel.getId(), form.getId()); + message.getId(), channel.getId(), form.id()); } /** @@ -68,7 +70,7 @@ public void attachForm(FormData form, MessageChannel channel, Message message) { * @param form form to close */ public void closeForm(FormData form) { - jdbcTemplate.update("update `forms` set `closed` = true where `form_id` = ?", form.getId()); + jdbcTemplate.update("update `forms` set `closed` = true where `form_id` = ?", form.id()); } /** @@ -77,7 +79,7 @@ public void closeForm(FormData form) { * @param form form to delete */ public void deleteForm(FormData form) { - jdbcTemplate.update("delete from `forms` where `form_id` = ?", form.getId()); + jdbcTemplate.update("delete from `forms` where `form_id` = ?", form.id()); } /** @@ -90,7 +92,7 @@ public void deleteForm(FormData form) { public int deleteSubmissions(FormData form, User user) { Objects.requireNonNull(form); Objects.requireNonNull(user); - return jdbcTemplate.update("delete from `form_submissions` where `form_id` = ? and `user_id` = ?", form.getId(), + return jdbcTemplate.update("delete from `form_submissions` where `form_id` = ? and `user_id` = ?", form.id(), user.getIdLong()); } @@ -102,7 +104,7 @@ public int deleteSubmissions(FormData form, User user) { public void detachForm(FormData form) { Objects.requireNonNull(form); jdbcTemplate.update("update `forms` set `message_id` = NULL, `message_channel` = NULL where `form_id` = ?", - form.getId()); + form.id()); } /** @@ -137,7 +139,7 @@ public List getAllForms(boolean closed) { public Map getSubmissionsCountPerUser(FormData form) { Objects.requireNonNull(form); List users = jdbcTemplate.query("select * from `form_submissions` where `form_id` = ?", - (rs, rowNum) -> new FormUser(rs.getLong("user_id"), rs.getString("user_name")), form.getId()); + (rs, rowNum) -> new FormUser(rs.getLong("user_id"), rs.getString("user_name")), form.id()); Map map = new HashMap<>(); for (FormUser user : users) { map.merge(user, 1, Integer::sum); @@ -169,7 +171,7 @@ public Optional getForm(long formId) { public int getTotalSubmissionsCount(FormData form) { Objects.requireNonNull(form); return jdbcTemplate.queryForObject("select count(*) from `form_submissions` where `form_id` = ?", - (rs, rowNum) -> rs.getInt(1), form.getId()); + (rs, rowNum) -> rs.getInt(1), form.id()); } /** @@ -184,7 +186,7 @@ public boolean hasSubmitted(User user, FormData form) { try { return jdbcTemplate.queryForObject( "select * from `form_submissions` where `user_id` = ? and `form_id` = ? limit 1", - (rs, rowNum) -> true, user.getIdLong(), form.getId()); + (rs, rowNum) -> true, user.getIdLong(), form.id()); } catch (EmptyResultDataAccessException e) { return false; } @@ -200,13 +202,14 @@ public void insertForm(@NonNull FormData data) { jdbcTemplate.update(con -> { PreparedStatement statement = con.prepareStatement( "insert into `forms` (title, submit_message, submit_channel, message_id, message_channel, expiration, onetime) values (?, ?, ?, ?, ?, ?, ?)"); - statement.setString(1, data.getTitle()); - statement.setString(2, data.getSubmitMessage()); - statement.setLong(3, data.getSubmitChannel()); + statement.setString(1, data.title()); + statement.setString(2, data.submitMessage()); + statement.setLong(3, data.submitChannel()); statement.setObject(4, data.getMessageId().orElse(null)); statement.setObject(5, data.getMessageChannel().orElse(null)); - statement.setLong(6, data.getExpiration()); - statement.setBoolean(7, data.isOnetime()); + statement.setTimestamp(6, + data.hasExpirationTime() ? new Timestamp(data.expiration().toEpochMilli()) : null); + statement.setBoolean(7, data.onetime()); return statement; }); } @@ -226,7 +229,7 @@ public void addSubmission(User user, FormData form, Message message) { "insert into `form_submissions` (`message_id`, `user_id`, `form_id`, `user_name`) values (?, ?, ?, ?)"); statement.setLong(1, message.getIdLong()); statement.setLong(2, user.getIdLong()); - statement.setLong(3, form.getId()); + statement.setLong(3, form.id()); statement.setString(4, user.getName()); return statement; }); @@ -239,7 +242,7 @@ public void addSubmission(User user, FormData form, Message message) { * @param index index of the field to remove */ public void removeField(FormData form, int index) { - List fields = form.getFields(); + List fields = form.fields(); if (index < 0 || index >= fields.size()) return; jdbcTemplate.update("delete from `form_fields` where `id` = ?", fields.get(index).id()); } @@ -250,7 +253,7 @@ public void removeField(FormData form, int index) { * @param form form to re-open */ public void reopenForm(FormData form) { - jdbcTemplate.update("update `forms` set `closed` = false where `form_id` = ?", form.getId()); + jdbcTemplate.update("update `forms` set `closed` = false where `form_id` = ?", form.id()); } /** @@ -264,12 +267,13 @@ public void updateForm(FormData newData) { jdbcTemplate.update(con -> { PreparedStatement statement = con.prepareStatement( "update `forms` set `title` = ?, `submit_channel` = ?, `submit_message` = ?, `expiration` = ?, `onetime` = ? where `form_id` = ?"); - statement.setString(1, newData.getTitle()); - statement.setLong(2, newData.getSubmitChannel()); - statement.setString(3, newData.getSubmitMessage()); - statement.setLong(4, newData.getExpiration()); - statement.setBoolean(5, newData.isOnetime()); - statement.setLong(6, newData.getId()); + statement.setString(1, newData.title()); + statement.setLong(2, newData.submitChannel()); + statement.setString(3, newData.submitMessage()); + statement.setTimestamp(4, + newData.hasExpirationTime() ? new Timestamp(newData.expiration().toEpochMilli()) : null); + statement.setBoolean(5, newData.onetime()); + statement.setLong(6, newData.id()); return statement; }); } @@ -284,9 +288,11 @@ private static FormData read(ResultSet rs, List fields) throws SQLExc if (rs.wasNull()) messageId = null; Long messageChannel = rs.getLong("message_channel"); if (rs.wasNull()) messageChannel = null; + Timestamp timestamp = rs.getTimestamp("expiration"); + Instant expiration = timestamp == null ? null : timestamp.toInstant(); return new FormData(rs.getLong("form_id"), fields, rs.getString("title"), rs.getLong("submit_channel"), - rs.getString("submit_message"), messageId, messageChannel, rs.getLong("expiration"), - rs.getBoolean("closed"), rs.getBoolean("onetime")); + rs.getString("submit_message"), messageId, messageChannel, expiration, rs.getBoolean("closed"), + rs.getBoolean("onetime")); } private static FormField readField(ResultSet rs) throws SQLException { diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java index 3a7c0aabe..c86a977fd 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java @@ -1,6 +1,6 @@ package net.discordjug.javabot.systems.staff_commands.forms.model; -import java.util.Collections; +import java.time.Instant; import java.util.Date; import java.util.List; import java.util.Objects; @@ -13,7 +13,9 @@ /** * Class containing information about a form. */ -public class FormData { +// TODO `Optional` getter for the submit message +public record FormData(long id, List fields, String title, long submitChannel, String submitMessage, + Long messageId, Long messageChannel, Instant expiration, boolean closed, boolean onetime) { /** * Setting {@link FormData#expiration} to this value indicates, that the form @@ -21,54 +23,9 @@ public class FormData { */ public static final long EXPIRATION_PERMANENT = -1; - private final boolean closed; - private final long expiration; - private final List fields; - private final long id; - private final Long messageId; - private final Long messageChannel; - private final boolean onetime; - private final long submitChannel; - private final String submitMessage; - private final String title; - - /** - * Main constructor. - * - * @param id The id of this form. The id should be equal to - * timestamp of creation of this form. - * @param fields List of text inputs of this form. - * @param title Form title shown in the submission modal and in various - * commands. - * @param submitChannel Target channel where the form submissions will be sent. - * @param submitMessage A message presented to the user after they successfully - * submit the form. - * @param messageId ID of the message this form is attached to. A null - * value indicates that this form is not attached to any - * message. - * @param messageChannel Channel of the message this form is attached to. A null - * value indicates that this form is not attached. - * @param expiration Time after which this form will not accept further - * submissions. Value of - * {@link FormData#EXPIRATION_PERMANENT} indicates that - * this form will never expire. - * @param closed Closed state of this form. A closed form doesn't accept - * further submissions and has its components disabled. - * @param onetime Whether or not this form accepts one submission per - * user. - */ - public FormData(long id, List fields, String title, long submitChannel, String submitMessage, - Long messageId, Long messageChannel, long expiration, boolean closed, boolean onetime) { - this.id = id; - this.fields = Objects.requireNonNull(fields); - this.title = Objects.requireNonNull(title); - this.submitChannel = submitChannel; - this.submitMessage = submitMessage; - this.messageId = messageId; - this.messageChannel = messageChannel; - this.expiration = expiration; - this.closed = closed; - this.onetime = onetime; + public FormData { + Objects.requireNonNull(title); + fields = List.copyOf(fields); } public boolean isAttached() { @@ -88,45 +45,13 @@ public LayoutComponent[] createComponents() { return array; } - public long getExpiration() { - return expiration; - } - - public List getFields() { - return fields == null ? Collections.emptyList() : fields; - } - - public long getId() { - return id; - } - - public Optional getMessageChannel() { - return Optional.ofNullable(messageChannel); - } - - public Optional getMessageId() { - return Optional.ofNullable(messageId); - } - - public long getSubmitChannel() { - return submitChannel; - } - - public String getSubmitMessage() { - return submitMessage; - } - - public String getTitle() { - return title; - } - /** * Checks if the form can expire. * * @return true if this form has an expiration time. */ public boolean hasExpirationTime() { - return expiration > 0; + return expiration != null; } /** @@ -136,15 +61,15 @@ public boolean hasExpirationTime() { * can't expire. */ public boolean hasExpired() { - return hasExpirationTime() && expiration < System.currentTimeMillis(); + return hasExpirationTime() && expiration.isBefore(Instant.now()); } - public boolean isClosed() { - return closed; + public Optional getMessageId() { + return Optional.ofNullable(messageId); } - public boolean isOnetime() { - return onetime; + public Optional getMessageChannel() { + return Optional.ofNullable(messageChannel); } @Override @@ -152,12 +77,13 @@ public String toString() { String prefix; if (closed) { prefix = "Closed"; - } else if (expiration == EXPIRATION_PERMANENT) { + } else if (!hasExpirationTime()) { prefix = "Permanent"; - } else if (expiration < System.currentTimeMillis()) { + } else if (hasExpired()) { prefix = "Expired"; } else { - prefix = FormInteractionManager.DATE_FORMAT.format(new Date(expiration)) + " UTC"; + // TODO change how date and time is formatted + prefix = FormInteractionManager.DATE_FORMAT.format(new Date(expiration.toEpochMilli())) + " UTC"; } return String.format("[%s] %s", prefix, title); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java index 92e0fc915..ce11fec19 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java @@ -19,11 +19,4 @@ public TextInput createTextInput(String id) { return TextInput.create(id, label(), style()).setRequiredRange(min(), max()).setPlaceholder(placeholder()) .setRequired(required()).setValue(value()).build(); } - - @Override - public String toString() { - return "FormField [label=" + label + ", max=" + max + ", min=" + min + ", placeholder=" + placeholder - + ", required=" + required + ", style=" + style + ", value=" + value + "]"; - } - } diff --git a/src/main/resources/database/migrations/09-08-2025_forms.sql b/src/main/resources/database/migrations/09-08-2025_forms.sql index e3d1d55bb..32dc7c389 100644 --- a/src/main/resources/database/migrations/09-08-2025_forms.sql +++ b/src/main/resources/database/migrations/09-08-2025_forms.sql @@ -5,34 +5,34 @@ CREATE TABLE forms ( submit_channel BIGINT NOT NULL, message_id BIGINT DEFAULT NULL, message_channel BIGINT DEFAULT NULL, - expiration BIGINT NOT NULL DEFAULT -1, + expiration TIMESTAMP DEFAULT NULL, closed BOOLEAN NOT NULL DEFAULT FALSE, onetime BOOLEAN NOT NULL DEFAULT FALSE, PRIMARY KEY (form_id) ); -CREATE TABLE FORM_FIELDS ( - ID BIGINT NOT NULL AUTO_INCREMENT, - FORM_ID BIGINT NOT NULL, - LABEL CHARACTER VARYING NOT NULL, - MIN INTEGER DEFAULT 0 NOT NULL, - MAX INTEGER DEFAULT 16 NOT NULL, - PLACEHOLDER CHARACTER VARYING, - REQUIRED BOOLEAN DEFAULT FALSE NOT NULL, +CREATE TABLE form_fields ( + id BIGINT NOT NULL AUTO_INCREMENT, + form_id BIGINT NOT NULL, + label VARCHAR NOT NULL, + min INTEGER DEFAULT 0 NOT NULL, + max INTEGER DEFAULT 16 NOT NULL, + placeholder VARCHAR, + "required" BOOLEAN DEFAULT FALSE NOT NULL, "style" ENUM('SHORT', 'PARAGRAPH') DEFAULT 'SHORT' NOT NULL, - INITIAL CHARACTER VARYING, - PRIMARY KEY (ID), - FOREIGN KEY (FORM_ID) REFERENCES FORMS(FORM_ID) ON DELETE CASCADE ON UPDATE RESTRICT + initial VARCHAR DEFAULT NULL, + PRIMARY KEY (id), + FOREIGN KEY (form_id) REFERENCES forms(form_id) ON DELETE CASCADE ON UPDATE RESTRICT ); -CREATE TABLE FORM_SUBMISSIONS ( - ID BIGINT NOT NULL AUTO_INCREMENT, - MESSAGE_ID BIGINT NOT NULL, - USER_ID BIGINT NOT NULL, - FORM_ID BIGINT NOT NULL, - USER_NAME VARCHAR NOT NULL, - PRIMARY KEY (ID), - FOREIGN KEY (FORM_ID) REFERENCES FORMS(FORM_ID) ON DELETE CASCADE ON UPDATE RESTRICT +CREATE TABLE form_submissions ( + id BIGINT NOT NULL AUTO_INCREMENT, + message_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + form_id BIGINT NOT NULL, + user_name VARCHAR NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (form_id) REFERENCES FORMS(form_id) ON DELETE CASCADE ON UPDATE RESTRICT ); CREATE INDEX FORM_SUBMISSIONS_USER_ID_IDX ON form_submissions (user_id,form_id); From fdd328e5fb89f140afabd164e2e7f8f2776e9e7e Mon Sep 17 00:00:00 2001 From: Defective Date: Sat, 20 Sep 2025 11:01:56 +0200 Subject: [PATCH 038/168] Form field javadoc --- .../javabot/systems/staff_commands/forms/model/FormField.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java index ce11fec19..586137a2a 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java @@ -5,12 +5,14 @@ /** * Represents a form field. + * Form fields are used to store data about text inputs presented to the user in the form modal. + * Each form can have up to 5 fields. */ public record FormField(String label, int max, int min, String placeholder, boolean required, TextInputStyle style, String value, long id) { /** - * Create a standalone text input from this field. + * Create a text input from this field. * * @param id ID of this text input. * @return text input ready to use in a modal. From 123282b833599f2dea424af74f747d88328b60d3 Mon Sep 17 00:00:00 2001 From: Defective Date: Sat, 20 Sep 2025 11:04:47 +0200 Subject: [PATCH 039/168] Convert `FormUser` to a `record` --- .../SubmissionsExportFormSubcommand.java | 6 +-- .../staff_commands/forms/model/FormUser.java | 51 +++++-------------- 2 files changed, 17 insertions(+), 40 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java index 83e385bd4..9b320ff60 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java @@ -61,10 +61,10 @@ public void execute(SlashCommandInteractionEvent event) { JsonArray users = new JsonArray(); for (Entry entry : submissions.entrySet()) { JsonObject uobj = new JsonObject(); - uobj.addProperty("username", entry.getKey().getUsername()); + uobj.addProperty("username", entry.getKey().username()); uobj.addProperty("submissions", entry.getValue()); - details.add(Long.toString(entry.getKey().getId()), uobj); - users.add(entry.getKey().getUsername()); + details.add(Long.toString(entry.getKey().id()), uobj); + users.add(entry.getKey().username()); } root.add("users", users); root.add("details", details); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java index 12ae3a920..62de400ed 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java @@ -1,44 +1,21 @@ package net.discordjug.javabot.systems.staff_commands.forms.model; -import java.util.Objects; - /** * Represents a user who submitted a form. */ -public class FormUser { - private final long id; - private final String username; - - /** - * The main constructor. - * - * @param id user's id - * @param username user's username - */ - public FormUser(long id, String username) { - this.id = id; - this.username = username; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (obj == null || getClass() != obj.getClass()) return false; - FormUser other = (FormUser) obj; - return id == other.id && Objects.equals(username, other.username); - } - - public long getId() { - return id; - } - - public String getUsername() { - return username; - } - - @Override - public int hashCode() { - return Objects.hash(id, username); - } +public record FormUser(long id, String username) { + +// @Override +// public boolean equals(Object obj) { +// if (this == obj) return true; +// if (obj == null || getClass() != obj.getClass()) return false; +// FormUser other = (FormUser) obj; +// return id == other.id && Objects.equals(username, other.username); +// } + +// @Override +// public int hashCode() { +// return Objects.hash(id, username); +// } } From 7fccf8e2b2a59cee7caa41edcc8d18b95c8df79d Mon Sep 17 00:00:00 2001 From: Defective Date: Sat, 20 Sep 2025 11:07:41 +0200 Subject: [PATCH 040/168] `FormCommand` javadoc --- .../systems/staff_commands/forms/commands/FormCommand.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java index 2a2dc94cf..91529707e 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java @@ -5,7 +5,8 @@ import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; /** - * The `/form` command. + * The {@code /form} command. This is the base command. It holds subcommands + * used to manage forms and their submissions. */ public class FormCommand extends SlashCommand { From 385820dced20941cc5a747cab3cfe2aa9e5f0f5c Mon Sep 17 00:00:00 2001 From: Defective Date: Sat, 20 Sep 2025 11:27:29 +0200 Subject: [PATCH 041/168] Fix checkstyle --- .../staff_commands/forms/model/FormData.java | 26 ++++++++++++ .../staff_commands/forms/model/FormField.java | 40 ++++++++++++++++--- .../staff_commands/forms/model/FormUser.java | 3 ++ 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java index c86a977fd..dacd9fe19 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java @@ -12,6 +12,26 @@ /** * Class containing information about a form. + * + * @param id the form ID. + * @param fields a list of text input fields associated with this form. + * A form can only hold a maximum of 5 fields at a time. + * @param title form title used in the modal displayed to the user. + * @param submitChannel ID of the channel the form submissions are sent to. + * @param submitMessage message displayed to the user once they submit the + * form. + * @param messageId ID of the message this form is attached to. null if the + * form is not attached to any message. + * @param messageChannel channel of the message this form is attached to. null + * if the form is not attached to any message. + * @param expiration time after which this user won't accept any further + * submissions. null to indicate that the form has no + * expiration date. + * @param closed closed state of this form. If the form is closed, it + * doesn't accept further submissions, even if it's + * expired. + * @param onetime onetime state of this form. If it's true, the form only + * accepts one submission per user. */ // TODO `Optional` getter for the submit message public record FormData(long id, List fields, String title, long submitChannel, String submitMessage, @@ -23,9 +43,15 @@ public record FormData(long id, List fields, String title, long submi */ public static final long EXPIRATION_PERMANENT = -1; + /** + * The main constructor. + */ public FormData { Objects.requireNonNull(title); fields = List.copyOf(fields); + if (fields.size() > 5) { + throw new IllegalArgumentException("fields.size() > 5"); + } } public boolean isAttached() { diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java index 586137a2a..77ea4ee75 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java @@ -1,15 +1,45 @@ package net.discordjug.javabot.systems.staff_commands.forms.model; +import java.util.Objects; + import net.dv8tion.jda.api.interactions.components.text.TextInput; import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; /** - * Represents a form field. - * Form fields are used to store data about text inputs presented to the user in the form modal. - * Each form can have up to 5 fields. + * Represents a form field. Form fields are used to store data about text inputs + * presented to the user in the form modal. Each form can have up to 5 fields. + * + * @param label field label. + * @param max maximum number of characters allowed to be entered in this + * field. + * @param min minimum number of characters required. Setting min to a + * value greater than 0 will make this field effectively + * required, even if the {@code required} parameter is set to + * false. + * @param placeholder field placeholder. Use null to use any placeholder. + * @param required whether or not the user has to type something in this + * field. + * @param style text input style. + * @param value initial field value. Can be null to indicate no inital + * value. + * @param id form id. */ -public record FormField(String label, int max, int min, String placeholder, boolean required, - TextInputStyle style, String value, long id) { +public record FormField(String label, int max, int min, String placeholder, boolean required, TextInputStyle style, + String value, long id) { + + /** + * The main constructor. + */ + public FormField { + Objects.requireNonNull(label); + if (min < 0) throw new IllegalArgumentException("min < 0"); + + if (max < 1) throw new IllegalArgumentException("max < 1"); + + if (max < min) throw new IllegalArgumentException("max < min"); + + Objects.requireNonNull(style); + } /** * Create a text input from this field. diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java index 62de400ed..5e0a15208 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java @@ -2,6 +2,9 @@ /** * Represents a user who submitted a form. + * + * @param id user's ID. + * @param username user's Discord username. */ public record FormUser(long id, String username) { From cef4187fb98323df7599e34dfd5732c119ee79f2 Mon Sep 17 00:00:00 2001 From: danthe1st Date: Mon, 20 Oct 2025 18:09:31 +0200 Subject: [PATCH 042/168] update to JDA 6.1.0 --- build.gradle.kts | 5 ++- gradle/wrapper/gradle-wrapper.properties | 2 +- .../data/h2db/commands/DbAdminCommand.java | 3 +- .../h2db/commands/QuickMigrateSubcommand.java | 14 +++---- .../data/h2db/message_cache/MessageCache.java | 5 ++- .../javabot/listener/GitHubLinkListener.java | 7 ++-- .../JobChannelCloseOldPostsListener.java | 3 +- .../listener/QOTWSubmissionListener.java | 3 +- .../configuration/SetConfigSubcommand.java | 11 ++--- .../custom_vc/CustomVCButtonHandler.java | 4 +- .../systems/custom_vc/CustomVCListener.java | 3 +- .../commands/CustomVCControlCommand.java | 3 +- .../systems/help/AutoCodeFormatter.java | 6 ++- .../systems/help/HelpForumUpdater.java | 5 ++- .../javabot/systems/help/HelpListener.java | 42 ++++++++++++------- .../javabot/systems/help/HelpManager.java | 8 ++-- .../systems/help/commands/CloseCommand.java | 5 ++- .../systems/help/commands/HelpCommand.java | 3 +- .../help/commands/UnreserveCommand.java | 18 ++++---- .../notify/ClearOldHelpNotificationJob.java | 7 +++- .../commands/notify/HelpPingSubcommand.java | 9 ++-- .../CommandModerationPermissions.java | 4 +- .../moderation/report/ReportCommand.java | 3 +- .../moderation/report/ReportManager.java | 26 ++++++------ .../report/ReportMessageContext.java | 3 +- .../moderation/report/ReportUserContext.java | 3 +- .../moderation/warn/WarnsListCommand.java | 3 +- .../moderation/warn/WarnsListContext.java | 3 +- .../qotw/commands/QOTWAdminCommand.java | 3 +- .../AddQuestionSubcommand.java | 14 +++---- .../qotw/jobs/QOTWCloseSubmissionsJob.java | 10 ++--- .../javabot/systems/qotw/jobs/QOTWJob.java | 4 +- .../SubmissionInteractionManager.java | 2 +- .../qotw/submissions/SubmissionManager.java | 6 +-- .../staff_commands/RedeployCommand.java | 3 +- .../RunScheduledTaskCommand.java | 3 +- .../systems/staff_commands/SayCommand.java | 3 +- .../embeds/AddEmbedFieldSubcommand.java | 16 +++---- .../embeds/CreateEmbedSubcommand.java | 23 +++++----- .../embeds/EditEmbedSubcommand.java | 42 +++++++++---------- .../role_emoji/RoleEmojiCommand.java | 3 +- .../ChangeSelfRoleStatusSubcommand.java | 4 +- .../self_roles/CreateSelfRoleSubcommand.java | 11 +++-- .../self_roles/SelfRoleCommand.java | 3 +- .../SelfRoleInteractionManager.java | 32 +++++++------- .../suggestions/SuggestionCommand.java | 3 +- .../suggestions/SuggestionSubcommand.java | 4 +- .../commands/CreateCustomTagSubcommand.java | 18 ++++---- .../commands/EditCustomTagSubcommand.java | 16 +++---- .../tags/commands/TagsCommand.java | 4 +- .../systems/starboard/StarboardManager.java | 5 ++- .../systems/user_commands/AvatarCommand.java | 3 +- .../systems/user_commands/BotInfoCommand.java | 8 ++-- .../user_commands/ChangeMyMindCommand.java | 3 +- .../user_commands/IdCalculatorCommand.java | 3 +- .../MoveConversationCommand.java | 8 ++-- .../systems/user_commands/PingCommand.java | 3 +- .../systems/user_commands/PollCommand.java | 3 +- .../systems/user_commands/ProfileCommand.java | 3 +- .../systems/user_commands/RegexCommand.java | 3 +- .../user_commands/ServerInfoCommand.java | 8 ++-- .../systems/user_commands/UptimeCommand.java | 3 +- .../FormatAndIndentCodeMessageContext.java | 3 +- .../format_code/FormatCodeCommand.java | 7 ++-- .../format_code/FormatCodeMessageContext.java | 3 +- .../ExperienceLeaderboardSubcommand.java | 4 +- .../leaderboard/LeaderboardCommand.java | 3 +- .../search/SearchWebCommand.java | 3 +- .../search/SearchWebMessageContext.java | 3 +- .../commands/PreferencesCommand.java | 3 +- .../javabot/util/InteractionUtils.java | 21 +++++----- .../javabot/util/MessageActionUtils.java | 18 ++++---- 72 files changed, 316 insertions(+), 243 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 723ddfe16..03289e8c9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,8 +32,9 @@ dependencies { compileOnly("org.jetbrains:annotations:26.0.2") // DIH4JDA (Command Framework) & JDA - implementation("com.github.DynxstyGIT:DIH4JDA:a64b5a9dc5") - implementation("net.dv8tion:JDA:5.6.1") { + implementation("com.github.jasonlessenich:DIH4JDA:1.7.0") + //implementation("xyz.dynxsty:dih4jda:1.7.0") + implementation("net.dv8tion:JDA:6.1.0") { exclude(module = "opus-java") } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 002b867c4..2e1113280 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/java/net/discordjug/javabot/data/h2db/commands/DbAdminCommand.java b/src/main/java/net/discordjug/javabot/data/h2db/commands/DbAdminCommand.java index 75fbdafe6..9f10f19f2 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/commands/DbAdminCommand.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/commands/DbAdminCommand.java @@ -4,6 +4,7 @@ import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; import net.discordjug.javabot.data.config.BotConfig; import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.interactions.InteractionContextType; import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions; import net.dv8tion.jda.api.interactions.commands.build.Commands; import net.dv8tion.jda.api.interactions.commands.build.SubcommandGroupData; @@ -27,7 +28,7 @@ public DbAdminCommand(BotConfig botConfig, ExportSchemaSubcommand exportSchemaSu setRegistrationType(RegistrationType.GUILD); setCommandData(Commands.slash("db-admin", "(ADMIN ONLY) Administrative Commands for managing the bot's database.") .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER)) - .setGuildOnly(true) + .setContexts(InteractionContextType.GUILD) ); addSubcommands(exportSchemaSubcommand, exportTableSubcommand, migrationsListSubcommand, migrateSubcommand, quickMigrateSubcommand); addSubcommandGroups(SubcommandGroup.of( diff --git a/src/main/java/net/discordjug/javabot/data/h2db/commands/QuickMigrateSubcommand.java b/src/main/java/net/discordjug/javabot/data/h2db/commands/QuickMigrateSubcommand.java index 851766622..c6dd89a2d 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/commands/QuickMigrateSubcommand.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/commands/QuickMigrateSubcommand.java @@ -1,5 +1,6 @@ package net.discordjug.javabot.data.h2db.commands; +import net.dv8tion.jda.api.modals.Modal; import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; import xyz.dynxsty.dih4jda.interactions.components.ModalHandler; import net.discordjug.javabot.annotations.AutoDetectableComponentHandler; @@ -7,14 +8,13 @@ import net.discordjug.javabot.util.ExceptionLogger; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.components.label.Label; +import net.dv8tion.jda.api.components.textinput.TextInput; +import net.dv8tion.jda.api.components.textinput.TextInputStyle; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; -import net.dv8tion.jda.api.interactions.components.ActionRow; -import net.dv8tion.jda.api.interactions.modals.Modal; -import net.dv8tion.jda.api.interactions.components.text.TextInput; -import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; import net.dv8tion.jda.api.interactions.modals.ModalMapping; import org.jetbrains.annotations.NotNull; @@ -105,7 +105,7 @@ public void handleModal(@NotNull ModalInteractionEvent event, List } private @NotNull Modal buildQuickMigrateModal() { - TextInput sqlInput = TextInput.create("sql", "SQL-Statement (H2)", TextInputStyle.PARAGRAPH) + TextInput sqlInput = TextInput.create("sql", TextInputStyle.PARAGRAPH) .setPlaceholder(""" CREATE TABLE my_table ( thread_id BIGINT PRIMARY KEY, @@ -114,12 +114,12 @@ CREATE TABLE my_table ( """) .setRequired(true) .build(); - TextInput confirmInput = TextInput.create("confirmation", "Confirmation", TextInputStyle.SHORT) + TextInput confirmInput = TextInput.create("confirmation", TextInputStyle.SHORT) .setPlaceholder("Type 'CONFIRM' to confirm this action") .setRequired(true) .build(); return Modal.create("quick-migrate", "Quick Migrate") - .addComponents(ActionRow.of(sqlInput), ActionRow.of(confirmInput)) + .addComponents(Label.of("SQL-Statement (H2)", sqlInput), Label.of("Confirmation", confirmInput)) .build(); } } diff --git a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java index 606fec2fb..cb3c1524a 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java @@ -12,10 +12,11 @@ import net.discordjug.javabot.util.TimeUtils; import net.discordjug.javabot.util.UserUtils; import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; import net.dv8tion.jda.api.entities.*; import net.dv8tion.jda.api.entities.Message.Attachment; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; -import net.dv8tion.jda.api.interactions.components.buttons.Button; import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; import net.dv8tion.jda.api.utils.FileUpload; @@ -120,7 +121,7 @@ public void sendUpdatedMessageToLog(Message updated, CachedMessage before) { if (updated.getContentRaw().trim().equals(before.getMessageContent()) && updated.getAttachments().size() == before.getAttachments().size()) return; MessageCreateAction action = config.getMessageCacheLogChannel() .sendMessageEmbeds(buildMessageEditEmbed(updated.getGuild(), updated.getAuthor(), updated.getChannel(), before, updated)) - .setActionRow(Button.link(updated.getJumpUrl(), "Jump to Message")); + .addComponents(ActionRow.of(Button.link(updated.getJumpUrl(), "Jump to Message"))); if (before.getMessageContent().length() > MessageEmbed.VALUE_MAX_LENGTH || updated.getContentRaw().length() > MessageEmbed.VALUE_MAX_LENGTH) { action.addFiles(FileUpload.fromData(buildEditedMessageFile(updated.getAuthor(), before, updated), before.getMessageId() + ".txt")); } diff --git a/src/main/java/net/discordjug/javabot/listener/GitHubLinkListener.java b/src/main/java/net/discordjug/javabot/listener/GitHubLinkListener.java index 13d200a16..b90c5b89a 100644 --- a/src/main/java/net/discordjug/javabot/listener/GitHubLinkListener.java +++ b/src/main/java/net/discordjug/javabot/listener/GitHubLinkListener.java @@ -4,9 +4,10 @@ import net.discordjug.javabot.util.ExceptionLogger; import net.discordjug.javabot.util.InteractionUtils; import net.discordjug.javabot.util.StringUtils; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; -import net.dv8tion.jda.api.interactions.components.buttons.Button; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; @@ -37,7 +38,7 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { if (!content.getFirst().isBlank() && !content.getSecond().isBlank()) { event.getMessage().reply(String.format("```%s\n%s\n```", content.getSecond(), StringUtils.standardSanitizer().compute(content.getFirst()))) .setAllowedMentions(List.of()) - .setActionRow(InteractionUtils.createDeleteButton(event.getAuthor().getIdLong()), Button.link(matcher.group(), "View on GitHub")) + .addComponents(ActionRow.of(InteractionUtils.createDeleteButton(event.getAuthor().getIdLong()), Button.link(matcher.group(), "View on GitHub"))) .queue(); } } @@ -56,7 +57,7 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { String[] segments = Arrays.copyOfRange(arr, 3, arr.length); // The file name, split by "." String[] file = segments[segments.length - 1].split("\\."); - Integer[] lines = Arrays.stream(file[1].split("L")) + Integer[] lines = Arrays.stream(file[file.length-1].split("L")) .map(line -> line.replace("-", "")) .filter(line -> line.matches("-?\\d+")) // check if the given link is a number .map(Integer::valueOf).sorted().toArray(Integer[]::new); diff --git a/src/main/java/net/discordjug/javabot/listener/JobChannelCloseOldPostsListener.java b/src/main/java/net/discordjug/javabot/listener/JobChannelCloseOldPostsListener.java index d61751653..aec7cadaa 100644 --- a/src/main/java/net/discordjug/javabot/listener/JobChannelCloseOldPostsListener.java +++ b/src/main/java/net/discordjug/javabot/listener/JobChannelCloseOldPostsListener.java @@ -9,6 +9,7 @@ import net.discordjug.javabot.data.config.guild.ModerationConfig; import net.discordjug.javabot.util.InteractionUtils; import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.components.actionrow.ActionRow; import net.dv8tion.jda.api.entities.UserSnowflake; import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; @@ -78,7 +79,7 @@ public void onChannelCreate(ChannelCreateEvent event) { .setDescription("Since only one open post is allowed per user, older posts have been closed") .setColor(Color.YELLOW) .build()) - .addActionRow(InteractionUtils.createDeleteButton(post.getOwnerIdLong())) + .addComponents(ActionRow.of(InteractionUtils.createDeleteButton(post.getOwnerIdLong()))) .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/listener/QOTWSubmissionListener.java b/src/main/java/net/discordjug/javabot/listener/QOTWSubmissionListener.java index 717cf4113..73c2a4791 100644 --- a/src/main/java/net/discordjug/javabot/listener/QOTWSubmissionListener.java +++ b/src/main/java/net/discordjug/javabot/listener/QOTWSubmissionListener.java @@ -4,6 +4,7 @@ import net.discordjug.javabot.data.config.BotConfig; import net.discordjug.javabot.data.config.guild.QOTWConfig; import net.discordjug.javabot.util.InteractionUtils; +import net.dv8tion.jda.api.components.actionrow.ActionRow; import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; @@ -35,7 +36,7 @@ public void onMessageReceived(@NotNull MessageReceivedEvent event) { Please keep in mind that messages **over 2000 characters** get split in half due to webhook limitations. If you want to make sure that your submission is properly formatted, split your message into smaller chunks instead.""", event.getAuthor().getAsMention()) - .setActionRow(InteractionUtils.createDeleteButton(event.getAuthor().getIdLong())) + .addComponents(ActionRow.of(InteractionUtils.createDeleteButton(event.getAuthor().getIdLong()))) .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/configuration/SetConfigSubcommand.java b/src/main/java/net/discordjug/javabot/systems/configuration/SetConfigSubcommand.java index 6a903fcc5..8cdab6554 100644 --- a/src/main/java/net/discordjug/javabot/systems/configuration/SetConfigSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/configuration/SetConfigSubcommand.java @@ -5,6 +5,9 @@ import net.discordjug.javabot.data.config.GuildConfig; import net.discordjug.javabot.data.config.UnknownPropertyException; import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.components.label.Label; +import net.dv8tion.jda.api.components.textinput.TextInput; +import net.dv8tion.jda.api.components.textinput.TextInputStyle; import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; @@ -12,10 +15,8 @@ import net.dv8tion.jda.api.interactions.commands.OptionMapping; import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; -import net.dv8tion.jda.api.interactions.components.text.TextInput; -import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; -import net.dv8tion.jda.api.interactions.modals.Modal; import net.dv8tion.jda.api.interactions.modals.ModalMapping; +import net.dv8tion.jda.api.modals.Modal; import net.dv8tion.jda.api.requests.restaction.interactions.InteractionCallbackAction; import xyz.dynxsty.dih4jda.interactions.AutoCompletable; import xyz.dynxsty.dih4jda.interactions.components.ModalHandler; @@ -58,9 +59,9 @@ public InteractionCallbackAction handleConfigSubcommand(@Nonnull SlashCommand } return event.replyModal( Modal.create(ComponentIdBuilder.build("config-set", property), "Change configuration value") - .addActionRow(TextInput.create("value", "new value", TextInputStyle.PARAGRAPH) + .addComponents(Label.of("new value", TextInput.create("value", TextInputStyle.PARAGRAPH) .setValue(String.valueOf(resolved)) - .build()) + .build())) .build()); } String valueString = valueOption.getAsString().trim(); diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java index 541d9136c..4bd960b54 100644 --- a/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCButtonHandler.java @@ -6,8 +6,8 @@ import net.discordjug.javabot.annotations.AutoDetectableComponentHandler; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.components.buttons.Button; import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; -import net.dv8tion.jda.api.interactions.components.buttons.Button; import net.dv8tion.jda.api.requests.restaction.PermissionOverrideAction; import xyz.dynxsty.dih4jda.interactions.components.ButtonHandler; import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; @@ -40,7 +40,7 @@ public void handleButton(ButtonInteractionEvent event, Button button) { Responses.error(event, "Only the VC owner can use this.").queue(); return; } - String[] id = ComponentIdBuilder.split(button.getId()); + String[] id = ComponentIdBuilder.split(button.getCustomId()); switch (id[1]) { case MAKE_PRIVATE_ID -> changeVisibility(event, createMakePublicButton(), "This voice channel is now private.", PermissionOverrideAction::setDenied); diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCListener.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCListener.java index 8ecd66edb..0ae954dcf 100644 --- a/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCListener.java +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/CustomVCListener.java @@ -7,6 +7,7 @@ import net.discordjug.javabot.util.ExceptionLogger; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.components.actionrow.ActionRow; import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; import net.dv8tion.jda.api.entities.channel.middleman.StandardGuildChannel; import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion; @@ -62,7 +63,7 @@ private void createCustomVC(GuildVoiceUpdateEvent event, AudioChannelUnion vcJoi """) .build()) .addContent(event.getMember().getAsMention()) - .addActionRow(buttonHandler.createMakePrivateButton()) + .addComponents(ActionRow.of(buttonHandler.createMakePrivateButton())) .queue(); }); } diff --git a/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCControlCommand.java b/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCControlCommand.java index aac9ffecf..c3b03b288 100644 --- a/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCControlCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/custom_vc/commands/CustomVCControlCommand.java @@ -1,5 +1,6 @@ package net.discordjug.javabot.systems.custom_vc.commands; +import net.dv8tion.jda.api.interactions.InteractionContextType; import net.dv8tion.jda.api.interactions.commands.build.Commands; import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; @@ -14,7 +15,7 @@ public class CustomVCControlCommand extends SlashCommand { */ public CustomVCControlCommand(CustomVCAddMemberSubcommand addMemberSubcommand, CustomVCRemoveMemberSubcommand removeMemberSubcommand) { setCommandData(Commands.slash("vc-control", "Manages custom voice channels") - .setGuildOnly(true) + .setContexts(InteractionContextType.GUILD) ); addSubcommands(addMemberSubcommand, removeMemberSubcommand); } diff --git a/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java b/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java index 64b483ffd..3b2ab37d6 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java +++ b/src/main/java/net/discordjug/javabot/systems/help/AutoCodeFormatter.java @@ -9,6 +9,7 @@ import net.discordjug.javabot.util.InteractionUtils; import net.discordjug.javabot.util.WebhookUtil; import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.components.actionrow.ActionRow; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.MessageEmbed; @@ -19,6 +20,7 @@ import org.springframework.stereotype.Component; import javax.annotation.Nonnull; + import java.util.Objects; /** @@ -126,9 +128,9 @@ void handleMessageEvent(@Nonnull MessageReceivedEvent event, boolean isFirstMess private void sendFormatHint(MessageReceivedEvent event) { event.getMessage() .replyEmbeds(formatHintEmbed(event.getGuild())) - .addActionRow( + .addComponents(ActionRow.of( InteractionUtils.createDeleteButton(event.getAuthor().getIdLong()) - ).queue(); + )).queue(); } diff --git a/src/main/java/net/discordjug/javabot/systems/help/HelpForumUpdater.java b/src/main/java/net/discordjug/javabot/systems/help/HelpForumUpdater.java index eef9d7ef2..fe83c77c2 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/HelpForumUpdater.java +++ b/src/main/java/net/discordjug/javabot/systems/help/HelpForumUpdater.java @@ -8,6 +8,7 @@ import net.discordjug.javabot.systems.user_preferences.model.Preference; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.components.buttons.Button; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.MessageEmbed; @@ -104,7 +105,7 @@ private MessageEmbed createDMDormantInfo(ThreadChannel post, HelpConfig config) } private boolean isThanksMessage(@NotNull Message m) { - return m.getAuthor().isBot() && !m.getButtons().isEmpty() && - m.getButtons().stream().allMatch(b -> b.getId() != null && b.getId().contains(HelpManager.HELP_THANKS_IDENTIFIER)); + return m.getAuthor().isBot() && !m.getComponents().isEmpty() && + m.getComponents().stream().allMatch(c -> c instanceof Button b && b.getCustomId() != null && b.getCustomId().contains(HelpManager.HELP_THANKS_IDENTIFIER)); } } diff --git a/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java b/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java index 638e0a2cc..889d820a4 100644 --- a/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java +++ b/src/main/java/net/discordjug/javabot/systems/help/HelpListener.java @@ -11,6 +11,8 @@ import net.discordjug.javabot.util.InteractionUtils; import net.discordjug.javabot.util.Responses; import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.UserSnowflake; import net.dv8tion.jda.api.entities.channel.Channel; @@ -22,14 +24,13 @@ import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; -import net.dv8tion.jda.api.interactions.components.ActionRow; -import net.dv8tion.jda.api.interactions.components.buttons.Button; import org.jetbrains.annotations.NotNull; import xyz.dynxsty.dih4jda.interactions.components.ButtonHandler; import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; import java.util.*; +import java.util.stream.Stream; /** * Listens for all events related to the forum help channel system. @@ -161,10 +162,10 @@ private void replyCloseSuggestionIfPatternMatches(Message msg) { If you are not, please ignore this message. Note that you will not be able to send further messages here after this post have been closed but you will be able to create new posts. """) - .addActionRow( + .addComponents(ActionRow.of( createCloseSuggestionButton(msg.getChannel().asThreadChannel()), InteractionUtils.createDeleteButton(msg.getAuthor().getIdLong()) - ).queue(); + )).queue(); recentlyCloseSuggestedPosts.put( postId, System.currentTimeMillis() + SUGGEST_CLOSE_TIMEOUT @@ -256,16 +257,16 @@ private void handleHelpThanksInteraction(@NotNull ButtonInteractionEvent event, case "done" -> handleThanksCloseButton(event, manager, post, ""); case "cancel" -> event.deferEdit().flatMap(h -> event.getMessage().delete()).queue(); default -> { - List