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..ec00186ce --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java @@ -0,0 +1,274 @@ +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.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); + if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID) + && split[1].equals(Long.toString(form.id()))) { + 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.closed()) { + closeForm(event.getGuild(), form); + } + return; + } + + if (form.onetime() && 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.onetime() && formsRepo.hasSubmitted(event.getUser(), form)) { + event.getHook().sendMessage("You have already submitted this form").queue(); + return; + } + + 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.") + .queue(); + return; + } + + channel.sendMessageEmbeds(createSubmissionEmbed(form, values, event.getMember())).queue(msg -> { + formsRepo.addSubmission(event.getUser(), form, msg); + }); + + event.getHook() + .sendMessage(form.submitMessage() == null ? "Your submission was received!" : form.submitMessage()) + .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.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); + if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID) + && split[1].equals(Long.toString(form.id()))) { + 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.id()), form.title()) + .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. + * @throws IllegalArgumentException if the date doesn't follow the format. + */ + public static Optional parseExpiration(SlashCommandInteractionEvent event) + throws IllegalArgumentException { + String expirationStr = event.getOption("expiration", null, OptionMapping::getAsString); + Optional expiration; + if (expirationStr == null) { + expiration = Optional.empty(); + } else { + try { + expiration = Optional.of(FormInteractionManager.DATE_FORMAT.parse(expirationStr).toInstant()); + } catch (ParseException e) { + throw new IllegalArgumentException("Invalid date. You should follow the format `" + + FormInteractionManager.DATE_FORMAT_STRING + "`."); + } + } + + 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.closed() || 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.title(), true); + + int len = Math.min(values.size(), form.fields().size()); + for (int i = 0; i < len; i++) { + ModalMapping mapping = values.get(i); + FormField field = form.fields().get(i); + String value = mapping.getAsString(); + builder.addField(field.label(), 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..23a126b2c --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java @@ -0,0 +1,96 @@ +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")); + } + + @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.fields().size() >= 5) { + event.getHook().sendMessage("Can't add more than 5 components to a form").queue(); + return; + } + + formsRepo.addField(form, createFormFieldFromEvent(event)); + 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.id())).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, value, 0); + } +} 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..e42b5bdf9 --- /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.isAttached()) { + event.getHook() + .sendMessage("The form seems to already be attached to a message. Detach it before continuing.") + .queue(); + return; + } + + if (form.fields().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.id())).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.id()), buttonLabel); + + if (form.closed() || 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..e93400205 --- /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.closed()) { + 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.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 new file mode 100644 index 000000000..5958f143b --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java @@ -0,0 +1,68 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.time.Instant; +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; + try { + expirationOpt = FormInteractionManager.parseExpiration(event); + } catch (IllegalArgumentException e) { + event.getHook().sendMessage(e.getMessage()).queue(); + return; + } + + Instant expiration = expirationOpt.orElse(null); + + 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)); + + 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..4a9ba514d --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java @@ -0,0 +1,65 @@ +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.isAttached()) { + DetachFormSubcommand.detachFromMessage(form, event.getGuild()); + // TODO send a warning + } + + 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.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 new file mode 100644 index 000000000..6853f7bd0 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java @@ -0,0 +1,106 @@ +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.isAttached()) { + 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.id())).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) { + 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) { + String cptId = btn.getId(); + String[] split = ComponentIdBuilder.split(cptId); + if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID)) { + return !split[1].equals(Long.toString(form.id())); + } + } + 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..8a11df5fb --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java @@ -0,0 +1,107 @@ +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.id())).toList()).queue(); + } + + private EmbedBuilder createFormDetailsEmbed(FormData form, Guild guild) { + EmbedBuilder builder = new EmbedBuilder().setTitle("Form details"); + + 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.expiration().toEpochMilli() / 1000L) + : "`Never`", + true); + + 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*", + 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.submitMessage() == null ? "Default" : form.submitMessage(), true); + + addCodeblockField(builder, "Number of fields", form.fields().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..91529707e --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java @@ -0,0 +1,41 @@ +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 {@code /form} command. This is the base command. It holds subcommands + * used to manage forms and their submissions. + */ +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..a9c937e5f --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java @@ -0,0 +1,93 @@ +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; +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.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.expiration(); + } else { + 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.onetime(), OptionMapping::getAsBoolean); + + FormData newForm = new FormData(oldForm.id(), oldForm.fields(), title, submitChannel, submitMessage, + oldForm.getMessageId().orElse(null), oldForm.getMessageChannel().orElse(null), expiration, + oldForm.closed(), 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.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 new file mode 100644 index 000000000..13d22b588 --- /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.fields().size()) { + event.getHook().sendMessage("Field index out of bounds.").queue(); + return; + } + + 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(); + return; + } + + formsRepo.removeField(form, index); + + event.getHook().sendMessage("Removed field `" + form.fields().get(index).label() + "` 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.id())).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().fields(); + for (int i = 0; i < fields.size(); i++) { + choices.add(new Choice(fields.get(i).label(), 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..92bb867da --- /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.closed()) { + 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.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 new file mode 100644 index 000000000..10b004bc0 --- /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.fields().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.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 new file mode 100644 index 000000000..36f7ef470 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java @@ -0,0 +1,60 @@ +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.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; +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 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))); + } + + @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; + } + + User user = event.getOption("user", OptionMapping::getAsUser); + 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) { + event.replyChoices( + 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 new file mode 100644 index 000000000..9b320ff60 --- /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.getSubmissionsCountPerUser(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().username()); + uobj.addProperty("submissions", entry.getValue()); + details.add(Long.toString(entry.getKey().id()), uobj); + users.add(entry.getKey().username()); + } + root.add("users", users); + root.add("details", details); + event.getHook().sendFiles(FileUpload.fromData(gson.toJson(root).getBytes(StandardCharsets.UTF_8), + "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.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 new file mode 100644 index 000000000..e644475b8 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -0,0 +1,303 @@ +package net.discordjug.javabot.systems.staff_commands.forms.dao; + +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; +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.RowMapper; +import org.springframework.stereotype.Repository; + +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; +import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; + +/** + * Dao class that represents the FORMS table. + */ +@RequiredArgsConstructor +@Repository +public class FormsRepository { + private final JdbcTemplate jdbcTemplate; + + /** + * Add a field to a form. + * + * @param form form to add field to + * @param field field to add + */ + public void addField(FormData form, FormField field) { + jdbcTemplate.update( + "INSERT INTO form_fields (form_id, label, min, max, placeholder, \"required\", \"style\", initial) " + + "VALUES(?, ?, ?, ?, ?, ?, ?, ?)", + form.id(), field.label(), field.min(), field.max(), field.placeholder(), field.required(), + field.style().name(), field.value()); + } + + /** + * 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.id()); + } + + /** + * 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.id()); + } + + /** + * 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.id()); + } + + /** + * 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, User user) { + Objects.requireNonNull(form); + Objects.requireNonNull(user); + return jdbcTemplate.update("delete from `form_submissions` where `form_id` = ? and `user_id` = ?", form.id(), + user.getIdLong()); + } + + /** + * 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.id()); + } + + /** + * Get all forms from the database. + * + * @return A list of forms + */ + public List getAllForms() { + return jdbcTemplate.query("select * from `forms`", (rs, rowNum) -> read(rs, readFormFields(rowNum))); + } + + /** + * 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, readFormFields(rowNum))); + } + + /** + * 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 + */ + 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.id()); + Map map = new HashMap<>(); + for (FormUser user : users) { + map.merge(user, 1, Integer::sum); + } + return Collections.unmodifiableMap(map); + } + + /** + * Get a form for given ID. + * + * @param formId form ID to query + * @return optional form + */ + public Optional getForm(long formId) { + try { + return Optional.of(jdbcTemplate.queryForObject("select * from `forms` where `form_id` = ?", + (RowMapper) (rs, rowNum) -> read(rs, readFormFields(formId)), 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.id()); + } + + /** + * Checks if a 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.getIdLong(), form.id()); + } 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(con -> { + PreparedStatement statement = con.prepareStatement( + "insert into `forms` (title, submit_message, submit_channel, message_id, message_channel, expiration, onetime) values (?, ?, ?, ?, ?, ?, ?)"); + 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.setTimestamp(6, + data.hasExpirationTime() ? new Timestamp(data.expiration().toEpochMilli()) : null); + statement.setBoolean(7, data.onetime()); + return statement; + }); + } + + /** + * 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 addSubmission(User user, FormData form, Message message) { + Objects.requireNonNull(user); + Objects.requireNonNull(form); + jdbcTemplate.update(con -> { + PreparedStatement statement = con.prepareStatement( + "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.id()); + 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 = form.fields(); + if (index < 0 || index >= fields.size()) return; + jdbcTemplate.update("delete from `form_fields` where `id` = ?", fields.get(index).id()); + } + + /** + * 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.id()); + } + + /** + * 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.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; + }); + } + + 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 { + Long messageId = rs.getLong("message_id"); + 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, expiration, rs.getBoolean("closed"), + rs.getBoolean("onetime")); + } + + 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/FormData.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java new file mode 100644 index 000000000..dacd9fe19 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java @@ -0,0 +1,118 @@ +package net.discordjug.javabot.systems.staff_commands.forms.model; + +import java.time.Instant; +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; +import net.dv8tion.jda.api.interactions.components.LayoutComponent; + +/** + * 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, + Long messageId, Long messageChannel, Instant expiration, boolean closed, boolean onetime) { + + /** + * Setting {@link FormData#expiration} to this value indicates, that the form + * will never expire. + */ + 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() { + return messageChannel != null && messageId != null; + } + + /** + * Creates text 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()]; + for (int i = 0; i < array.length; i++) { + array[i] = ActionRow.of(fields.get(i).createTextInput("text" + i)); + } + return array; + } + + /** + * Checks if the form can expire. + * + * @return true if this form has an expiration time. + */ + public boolean hasExpirationTime() { + return expiration != null; + } + + /** + * 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.isBefore(Instant.now()); + } + + public Optional getMessageId() { + return Optional.ofNullable(messageId); + } + + public Optional getMessageChannel() { + return Optional.ofNullable(messageChannel); + } + + @Override + public String toString() { + String prefix; + if (closed) { + prefix = "Closed"; + } else if (!hasExpirationTime()) { + prefix = "Permanent"; + } else if (hasExpired()) { + prefix = "Expired"; + } else { + // 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 new file mode 100644 index 000000000..77ea4ee75 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java @@ -0,0 +1,54 @@ +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. + * + * @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) { + + /** + * 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. + * + * @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, label(), style()).setRequiredRange(min(), max()).setPlaceholder(placeholder()) + .setRequired(required()).setValue(value()).build(); + } +} 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..5e0a15208 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java @@ -0,0 +1,24 @@ +package net.discordjug.javabot.systems.staff_commands.forms.model; + +/** + * 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) { + +// @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); +// } + +} 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..32dc7c389 --- /dev/null +++ b/src/main/resources/database/migrations/09-08-2025_forms.sql @@ -0,0 +1,38 @@ +CREATE TABLE forms ( + form_id BIGINT NOT NULL AUTO_INCREMENT, + title VARCHAR NOT NULL, + submit_message VARCHAR DEFAULT NULL, + submit_channel BIGINT NOT NULL, + message_id BIGINT DEFAULT NULL, + message_channel BIGINT DEFAULT NULL, + 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 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 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 INDEX FORM_SUBMISSIONS_USER_ID_IDX ON form_submissions (user_id,form_id);