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..f602c3ea2 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java @@ -0,0 +1,290 @@ +package net.discordjug.javabot.systems.staff_commands.forms; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.discordjug.javabot.util.ExceptionLogger; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.actionrow.ActionRowChildComponent; +import net.dv8tion.jda.api.components.buttons.Button; +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.entities.channel.middleman.MessageChannel; +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.modals.ModalMapping; +import net.dv8tion.jda.api.modals.Modal; +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 +@Slf4j +public class FormInteractionManager implements ButtonHandler, ModalHandler { + + /** + * String representation of the date and time format used in forms. + */ + public static final String DATE_FORMAT_STRING = "dd.MM.yyyy HH:mm"; + + /** + * Date and time formatter used in forms. + */ + public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(DATE_FORMAT_STRING); + + /** + * Component ID used for form buttons and modals. + */ + public static final String FORM_COMPONENT_ID = "modal-form"; + + private static final String SUBMISSION_ERROR_LOG = "A user tried to submit a form \"%s\", but an error occured."; + + private static final String SUBMISSION_ERROR_MSG = "We couldn't receive your submission due to an error. Please contact server staff."; + + 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; + + /** + * 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); + + form.getAttachmentInfo().ifPresent(info -> { + long messageChannelId = info.messageChannelId(); + long messageId = info.messageId(); + MessageChannel formChannel = guild.getJDA().getChannelById(MessageChannel.class, messageChannelId); + formChannel.retrieveMessageById(messageId).queue(msg -> { + editFormMessageButtons(msg, btn -> { + String cptId = btn.getCustomId(); + 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; + }); + }, ExceptionLogger::capture); + }); + } + + @Override + public void handleButton(ButtonInteractionEvent event, Button button) { + long formId = Long.parseLong(ComponentIdBuilder.split(button.getCustomId())[1]); + Optional formOpt = formsRepo.getForm(formId); + if (!formOpt.isPresent()) { + event.reply(FORM_NOT_FOUND_MSG).setEphemeral(true).queue(); + return; + } + FormData form = formOpt.get(); + if (!isOpen(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 = createSubmissionModal(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 (!isOpen(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) { + log.warn("A user tried to submit a form \"%s\" because the submission channel does not exist." + .formatted(form.title())); + event.getHook().sendMessage(SUBMISSION_ERROR_MSG).queue(); + return; + } + + try { + channel.sendMessageEmbeds(createSubmissionEmbed(form, values, event.getMember())).queue(msg -> { + formsRepo.addSubmission(event.getUser(), form, msg); + event.getHook().sendMessage(form.getOptionalSubmitMessage().orElse("Your submission was received!")) + .queue(); + }, e -> { + event.getHook().sendMessage(SUBMISSION_ERROR_MSG).queue(); + log.error(SUBMISSION_ERROR_LOG.formatted(form.title())); + ExceptionLogger.capture(e); + }); + } catch (IllegalArgumentException e) { + event.getHook().sendMessage(SUBMISSION_ERROR_MSG).queue(); + log.error(SUBMISSION_ERROR_LOG.formatted(form.title())); + ExceptionLogger.capture(e); + } + } + + /** + * Modifies buttons in a message using given function for mapping. + * + * @param msg message to modify buttons in. + * @param editFunction function to edit the buttons. + */ + public void editFormMessageButtons(Message msg, Function editFunction) { + List components = msg.getComponents().stream().map(messageComponent -> { + ActionRow row = messageComponent.asActionRow(); + List cpts = row.getComponents().stream().map(cpt -> { + if (cpt instanceof Button btn) { + return editFunction.apply(btn); + } + return cpt; + }).toList(); + if (cpts.isEmpty()) { + return null; + } + return ActionRow.of(cpts); + }).filter(Objects::nonNull).toList(); + msg.editMessageComponents(components).queue(); + } + + /** + * Re-opens the form, re-enabling associated buttons in the 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); + + form.getAttachmentInfo().ifPresent(info -> { + long messageChannelId = info.messageChannelId(); + long messageId = info.messageId(); + TextChannel formChannel = guild.getTextChannelById(messageChannelId); + formChannel.retrieveMessageById(messageId).queue(msg -> { + editFormMessageButtons(msg, btn -> { + String cptId = btn.getCustomId(); + 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; + }); + }, ExceptionLogger::capture); + }); + } + + /** + * 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 createSubmissionModal(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, or an empty optional if it's + * not present. + * @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(LocalDateTime.parse(expirationStr, DATE_FORMATTER).toInstant(ZoneOffset.UTC)); + } catch (DateTimeParseException 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 isOpen(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", String.format("%s (`%s`)", author.getAsMention(), author.getId()), 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(); + if (value != null) value = value.replace("```", "` ` `"); + 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..6763f5ae8 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java @@ -0,0 +1,108 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Arrays; +import java.util.Optional; + +import net.discordjug.javabot.data.config.BotConfig; +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.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.components.textinput.TextInputStyle; +import net.dv8tion.jda.api.entities.Message; +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; + +/** + * The `/form add-field` command. This command allows for modification of + * {@link FormData} by adding new fields to it. See + * {@link RemoveFieldFormSubcommand} for the command used to remove fields from + * a form.
+ * Currently, due to Discord limitations, only 5 fields are allowed per form. + * Trying to add more fields will have no effect. + * + * @see FormData + */ +public class AddFieldFormSubcommand extends FormSubcommand implements AutoCompletable { + + private static final String FORM_VALUE_FIELD = "value"; + private static final String FORM_STYLE_FIELD = "style"; + private static final String FORM_REQUIRED_FIELD = "required"; + private static final String FORM_PLACEHOLDER_FIELD = "placeholder"; + private static final String FORM_MAX_FIELD = "max"; + private static final String FORM_MIN_FIELD = "min"; + private static final String FORM_LABEL_FIELD = "label"; + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public AddFieldFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("add-field", "Adds a field to an existing form") + .addOption(OptionType.INTEGER, FORM_ID_FIELD, "Form ID to add the field to", true, true) + .addOption(OptionType.STRING, FORM_LABEL_FIELD, "Field label", true) + .addOption(OptionType.INTEGER, FORM_MIN_FIELD, "Minimum number of characters") + .addOption(OptionType.INTEGER, FORM_MAX_FIELD, "Maximum number of characters") + .addOption(OptionType.STRING, FORM_PLACEHOLDER_FIELD, "Field placeholder") + .addOption(OptionType.BOOLEAN, FORM_REQUIRED_FIELD, + "Whether or not the user has to input data in this field. Default: false") + .addOptions(new OptionData(OptionType.STRING, FORM_STYLE_FIELD, "Input style. Default: SHORT", false) + .addChoices(Arrays.stream(TextInputStyle.values()).filter(t -> t != TextInputStyle.UNKNOWN) + .map(style -> new Choice(style.name(), style.name())).toList())) + .addOption(OptionType.STRING, FORM_VALUE_FIELD, "Initial field value")); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + Optional formOpt = formsRepo.getForm(event.getOption(FORM_ID_FIELD, OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + Responses.error(event, "A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + + if (form.fields().size() >= Message.MAX_COMPONENT_COUNT) { + Responses.error(event, "Can't add more than %s components to a form", Message.MAX_COMPONENT_COUNT).queue(); + return; + } + + formsRepo.addField(form, createFormFieldFromEvent(event)); + event.reply("Added a new field to the form.").setEphemeral(true).queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + handleFormIDAutocomplete(event, target); + } + + private static FormField createFormFieldFromEvent(SlashCommandInteractionEvent e) { + String label = e.getOption(FORM_LABEL_FIELD, OptionMapping::getAsString); + int min = e.getOption(FORM_MIN_FIELD, 0, OptionMapping::getAsInt); + int max = e.getOption(FORM_MAX_FIELD, 64, OptionMapping::getAsInt); + String placeholder = e.getOption(FORM_PLACEHOLDER_FIELD, OptionMapping::getAsString); + boolean required = e.getOption(FORM_REQUIRED_FIELD, false, OptionMapping::getAsBoolean); + TextInputStyle style = e.getOption(FORM_STYLE_FIELD, TextInputStyle.SHORT, t -> { + try { + return TextInputStyle.valueOf(t.getAsString().toUpperCase()); + } catch (IllegalArgumentException e2) { + return TextInputStyle.SHORT; + } + }); + String value = e.getOption(FORM_VALUE_FIELD, 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..2cd100ead --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java @@ -0,0 +1,157 @@ +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.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.components.Component.Type; +import net.dv8tion.jda.api.components.MessageTopLevelComponentUnion; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.actionrow.ActionRowChildComponent; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.components.buttons.ButtonStyle; +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 xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; + +/** + * The `/form attach` command. This command can be used to attach a form to an + * existing message. "Attaching" a form to message in this case means that the + * bot will modify the target message with a button, that when interacted with, + * will bring up a modal where the user can input their data. See + * {@link DetachFormSubcommand} for a command used to detach the form from a + * message. + * + * @see FormData + */ +public class AttachFormSubcommand extends FormSubcommand implements AutoCompletable { + + private static final String FORM_BUTTON_STYLE_FIELD = "button-style"; + private static final String FORM_BUTTON_LABEL_FIELD = "button-label"; + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public AttachFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("attach", "Add a button for bringing up the form to a message").addOptions( + new OptionData(OptionType.INTEGER, FORM_ID_FIELD, "ID of the form to attach", true, true), + new OptionData(OptionType.STRING, FORM_MESSAGE_ID_FIELD, "ID of the message to attach the form to", + true), + new OptionData(OptionType.CHANNEL, FORM_CHANNEL_FIELD, + "Channel of the message. Required if the message is in a different channel"), + new OptionData(OptionType.STRING, FORM_BUTTON_LABEL_FIELD, + "Label of the submit button. Default is \"Submit\""), + new OptionData(OptionType.STRING, FORM_BUTTON_STYLE_FIELD, "Submit button style. Defaults to primary", + false) + .addChoices(Set + .of(ButtonStyle.DANGER, ButtonStyle.PRIMARY, ButtonStyle.SECONDARY, ButtonStyle.SUCCESS) + .stream().map(style -> new Choice(style.name(), style.name())).toList()))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + event.deferReply().setEphemeral(true).queue(); + + Optional formOpt = formsRepo.getForm(event.getOption(FORM_ID_FIELD, OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + + if (form.getAttachmentInfo().isPresent()) { + 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(FORM_MESSAGE_ID_FIELD, OptionMapping::getAsString); + GuildChannel channel = event.getOption(FORM_CHANNEL_FIELD, 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(FORM_BUTTON_LABEL_FIELD, "Submit", OptionMapping::getAsString); + ButtonStyle style = event.getOption(FORM_BUTTON_STYLE_FIELD, 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(); + }, _ -> event.getHook().sendMessage("A message with this ID was not found").queue()); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + handleFormIDAutocomplete(event, target); + } + + private static void attachFormToMessage(Message message, String buttonLabel, ButtonStyle style, FormData form) { + List rows = new ArrayList<>( + message.getComponents().stream().map(MessageTopLevelComponentUnion::asActionRow).toList()); + + 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() >= ActionRow.getMaxAllowed(Type.BUTTON)) { + rows.add(ActionRow.of(button)); + } else { + ActionRow lastRow = rows.get(rows.size() - 1); + List components = new ArrayList<>(lastRow.getComponents()); + components.add(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..9b2959270 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java @@ -0,0 +1,73 @@ +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.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; + +/** + * The `/form close` command. This command closes a form. A closed form doesn't + * accept any new submissions. See {@link ReopenFormSubcommand} for a command + * that can be used to re-open a closed form. + * + * @see FormData + */ +public class CloseFormSubcommand extends FormSubcommand 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) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + this.interactionManager = interactionManager; + setCommandData(new SubcommandData("close", "Close an existing form, preventing further submissions.") + .addOptions(new OptionData(OptionType.INTEGER, FORM_ID_FIELD, "The ID of a form to close", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + long id = event.getOption(FORM_ID_FIELD, 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) { + handleFormIDAutocomplete(event, target); + } +} 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..4ba052dc3 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java @@ -0,0 +1,75 @@ +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.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.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; + +/** + * The `/form create` command. This command creates a new, empty form. Newly + * created forms have no fields, and thus can't be attached (See + * {@link AttachFormSubcommand}) to messages. Use {@link AddFieldFormSubcommand} + * to add new fields to the form. + * + * @see FormData + */ +public class CreateFormSubcommand extends FormSubcommand { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public CreateFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("create", "Create a new form").addOptions( + new OptionData(OptionType.STRING, FORM_TITLE_FIELD, "Form title (shown in modal)", true), + new OptionData(OptionType.CHANNEL, FORM_SUBMIT_CHANNEL_FIELD, "Channel to log form submissions in", + true), + new OptionData(OptionType.STRING, FORM_SUBMIT_MESSAGE_FIELD, + "Message displayed to the user once they submit the form"), + new OptionData(OptionType.STRING, FORM_EXPIRATION_FIELD, + "UTC time after which the form will not accept further submissions. " + + FormInteractionManager.DATE_FORMAT_STRING), + new OptionData(OptionType.BOOLEAN, FORM_ONETIME_FIELD, + "If the form should only accept one submission per user. Defaults to false."))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + event.deferReply().setEphemeral(true).queue(); + 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(FORM_TITLE_FIELD, OptionMapping::getAsString), + event.getOption(FORM_SUBMIT_CHANNEL_FIELD, OptionMapping::getAsChannel).getIdLong(), + event.getOption(FORM_SUBMIT_MESSAGE_FIELD, null, OptionMapping::getAsString), null, null, expiration, + false, event.getOption(FORM_ONETIME_FIELD, 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..a874ce898 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java @@ -0,0 +1,70 @@ +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.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.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; + +/** + * The `/form delete` command. Deletes an existing form. This command also does + * delete submission records from the database. This command won't work if the form + * is attached to a message, see {@link DetachFormSubcommand} + * + * @see FormData + */ +public class DeleteFormSubcommand extends FormSubcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public DeleteFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("delete", "Delete an existing form").addOptions( + new OptionData(OptionType.INTEGER, FORM_ID_FIELD, "The ID of a form to delete", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + long id = event.getOption(FORM_ID_FIELD, 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(); + + if (form.getAttachmentInfo().isPresent()) { + event.getHook().sendMessage( + "This form is attached to a message. Use `details` subcommand to check the message this form is attached to, or `detach` subcommand to detach the message before deleting.") + .queue(); + return; + } + + formsRepo.deleteForm(form); + + event.getHook().sendMessage("Form deleted!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + handleFormIDAutocomplete(event, target); + } +} 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..e34197ba5 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java @@ -0,0 +1,118 @@ +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.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.discordjug.javabot.util.ExceptionLogger; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.actionrow.ActionRowChildComponentUnion; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.Guild; +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.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.util.ComponentIdBuilder; + +/** + * The `/form detach` command. This command detaches this form from the message + * it's attached to. Detaching a form means that any buttons that could be used + * to bring its input dialog will be removed. See {@link AttachFormSubcommand} + * for a command for attaching the form to a message. + * + * @see FormData + */ +public class DetachFormSubcommand extends FormSubcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public DetachFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("detach", + "Remove any buttons that could be used to bring the form's input modal") + .addOptions(new OptionData(OptionType.INTEGER, FORM_ID_FIELD, "ID of the form to attach", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + event.deferReply().setEphemeral(true).queue(); + + Optional formOpt = formsRepo.getForm(event.getOption(FORM_ID_FIELD, OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + + if (form.getAttachmentInfo().isEmpty()) { + 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) { + handleFormIDAutocomplete(event, target); + } + + /** + * 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) { + form.getAttachmentInfo().ifPresent(info -> { + long messageChannelId = info.messageChannelId(); + long messageId = info.messageId(); + MessageChannel formChannel = guild.getJDA().getChannelById(MessageChannel.class, messageChannelId); + if (formChannel != null) { + formChannel.retrieveMessageById(messageId).queue(msg -> { + List components = msg.getComponents().stream().map(msgComponent -> { + ActionRow row = msgComponent.asActionRow(); + List cpts = row.getComponents().stream().filter(cpt -> { + if (cpt instanceof Button btn) { + String cptId = btn.getCustomId(); + String[] split = ComponentIdBuilder.split(cptId); + if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID)) { + return !split[1].equals(Long.toString(form.id())); + } + } + return true; + }).toList(); + if (cpts.isEmpty()) { + return null; + } + return ActionRow.of(cpts); + }).filter(Objects::nonNull).toList(); + msg.editMessageComponents(components).queue(); + }, ExceptionLogger::capture); + } + }); + } + +} 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..f12dd59ab --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java @@ -0,0 +1,127 @@ +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.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormAttachmentInfo; +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.entities.Message; +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.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.MarkdownUtil; +import net.dv8tion.jda.api.utils.TimeFormat; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; + +/** + * The `/form details` command. Displays information about the specified form. + * The information is sent as a non-ephemeral embed in the same channel this + * command is executed in. + * + * @see FormData + */ +public class DetailsFormSubcommand extends FormSubcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public DetailsFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("details", "Get details about a form").addOptions( + new OptionData(OptionType.INTEGER, FORM_ID_FIELD, "The ID of a form to get details for", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + event.deferReply().setEphemeral(false).queue(); + Optional formOpt = formsRepo.getForm(event.getOption(FORM_ID_FIELD, 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()); + + event.getHook().sendMessageEmbeds(embedBuilder.build()).queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + handleFormIDAutocomplete(event, target); + } + + 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); + + builder.addField("Expires at", + form.hasExpirationTime() ? TimeFormat.DATE_TIME_LONG.format(form.expiration().toEpochMilli()) + : "`Never`", + true); + + addCodeblockField(builder, "State", form.closed() ? "Closed" : form.hasExpired() ? "Expired" : "Open", false); + + String channelMention; + String messageLink; + Optional attachmentInfoOptonal = form.getAttachmentInfo(); + + channelMention = attachmentInfoOptonal.map(info -> { + long channelId = info.messageChannelId(); + MessageChannel channel = guild.getJDA().getChannelById(MessageChannel.class, channelId); + return channel != null ? channel.getAsMention() : "`" + channelId + "`"; + }).orElse("*Not attached*"); + + messageLink = attachmentInfoOptonal.map(attachmentInfo -> { + long messageId = attachmentInfo.messageId(); + long channelId = attachmentInfo.messageChannelId(); + return MarkdownUtil.maskedLink("Link", + String.format(Message.JUMP_URL, guild.getId(), channelId, messageId)); + }).orElse("*Not attached*"); + + String submissionsChannelMention; + MessageChannel submissionsChannel = guild.getJDA().getChannelById(MessageChannel.class, form.submitChannel()); + if (submissionsChannel != null) { + submissionsChannelMention = submissionsChannel.getAsMention(); + } else { + submissionsChannelMention = "`" + form.submitChannel() + "`"; + } + + builder.addField("Attached in", channelMention, true); + builder.addField("Attached to", messageLink, true); + + builder.addField("Submissions channel", submissionsChannelMention, true); + builder.addField("Is one-time", form.onetime() ? ":white_check_mark:" : ":x:", true); + addCodeblockField(builder, "Submission message", form.getOptionalSubmitMessage().orElse("Default"), 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..f09eb438c --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java @@ -0,0 +1,44 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +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 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").setContexts(InteractionContextType.GUILD) + .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_SERVER))); + 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/FormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormSubcommand.java new file mode 100644 index 000000000..7bfaca3da --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormSubcommand.java @@ -0,0 +1,104 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.util.Checks; +import net.discordjug.javabot.util.Responses; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; +import xyz.dynxsty.dih4jda.util.AutoCompleteUtils; + +/** + * Base abstract class containing common methods used in form subcommands. + */ +public abstract class FormSubcommand extends Subcommand { + + /** + * Form ID field identificator used in form subcommands. + */ + protected static final String FORM_ID_FIELD = "form-id"; + + /** + * Channel field identifier. + */ + protected static final String FORM_CHANNEL_FIELD = "channel"; + + /** + * Message id field identifier. + */ + protected static final String FORM_MESSAGE_ID_FIELD = "message-id"; + + /** + * Expiration field identifier. + */ + protected static final String FORM_EXPIRATION_FIELD = "expiration"; + + /** + * "onetime" field identifier. + */ + protected static final String FORM_ONETIME_FIELD = "onetime"; + + /** + * Submit message field identifier. + */ + protected static final String FORM_SUBMIT_MESSAGE_FIELD = "submit-message"; + + /** + * Submit channel field identifier. + */ + protected static final String FORM_SUBMIT_CHANNEL_FIELD = "submit-channel"; + + /** + * Form title field identifier. + */ + protected static final String FORM_TITLE_FIELD = "title"; + private final BotConfig botConfig; + private final FormsRepository formsRepository; + + /** + * The main constructor. + * + * @param botConfig main bot configuration + * @param formsRepository the forms repository + */ + public FormSubcommand(BotConfig botConfig, FormsRepository formsRepository) { + this.botConfig = botConfig; + this.formsRepository = formsRepository; + } + + /** + * Check if the author of this event has the configured staff role. If not, + * reply to the event with a message and return `false` + * + * @param event event to reply to + * @return true if the user has the staff role + */ + protected boolean checkForStaffRole(IReplyCallback event) { + if (!Checks.hasStaffRole(botConfig, event.getMember())) { + Responses.replyStaffOnly(event, botConfig.get(event.getGuild())).queue(); + return false; + } + return true; + } + + /** + * Tries to handle the auto completion event initiated by a user. If current + * focused field's id is equal to {@link #FORM_ID_FIELD}, the method will handle + * the event by replying with a list of all available form IDs, + * + * @param event the event to handle + * @param target auto completion target + * @return true if the event was handled by this method + */ + protected boolean handleFormIDAutocomplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + if (FORM_ID_FIELD.equals(target.getName())) { + event.replyChoices(AutoCompleteUtils.filterChoices(event, formsRepository.getAllForms().stream() + .map(form -> new Choice(form.toString(), form.id())).toList())).queue(); + return true; + } + return false; + } +} 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..8628a8520 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java @@ -0,0 +1,117 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.time.Instant; +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.FormAttachmentInfo; +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.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; + +/** + * The `/form modify` command. Modifies attributes of an existing form. For + * modifying form fields see {@link AddFieldFormSubcommand} and + * {@link RemoveFieldFormSubcommand} + * + * @see FormData + */ +public class ModifyFormSubcommand extends FormSubcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public ModifyFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("modify", + "Modify an existing form's data. Use *-field commands to manage form fields") + .addOptions(new OptionData(OptionType.INTEGER, FORM_ID_FIELD, "ID of the form to modify", true, true), + new OptionData(OptionType.STRING, FORM_TITLE_FIELD, "Form title (shown in modal)"), + new OptionData(OptionType.CHANNEL, FORM_SUBMIT_CHANNEL_FIELD, + "Channel to log form submissions in"), + new OptionData(OptionType.STRING, FORM_SUBMIT_MESSAGE_FIELD, + "Message displayed to the user once they submit the form"), + new OptionData(OptionType.STRING, FORM_EXPIRATION_FIELD, + "UTC time after which the form stops accepting submissions. - for no expiration. " + + FormInteractionManager.DATE_FORMAT_STRING), + new OptionData(OptionType.BOOLEAN, FORM_ONETIME_FIELD, + "If the form should only accept one submission per user. Defaults to false."))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + event.deferReply(true).queue(); + Optional formOpt = formsRepo.getForm(event.getOption(FORM_ID_FIELD, 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(FORM_TITLE_FIELD, oldForm.title(), OptionMapping::getAsString); + long submitChannel = event.getOption(FORM_SUBMIT_CHANNEL_FIELD, oldForm.submitChannel(), + OptionMapping::getAsLong); + String submitMessage = event.getOption(FORM_SUBMIT_MESSAGE_FIELD, oldForm.submitMessage(), + OptionMapping::getAsString); + Instant expiration; + if (event.getOption(FORM_EXPIRATION_FIELD) == null) { + expiration = oldForm.expiration(); + } else { + if ("-".equals(event.getOption(FORM_EXPIRATION_FIELD, OptionMapping::getAsString))) { + expiration = null; + } 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(FORM_ONETIME_FIELD, oldForm.onetime(), OptionMapping::getAsBoolean); + + Long messageId; + Long messageChannel; + + Optional infoOptional = oldForm.getAttachmentInfo(); + if (infoOptional.isPresent()) { + FormAttachmentInfo info = infoOptional.get(); + messageId = info.messageId(); + messageChannel = info.messageChannelId(); + } else { + messageChannel = null; + messageId = null; + } + + FormData newForm = new FormData(oldForm.id(), oldForm.fields(), title, submitChannel, submitMessage, messageId, + messageChannel, expiration, oldForm.closed(), onetime); + + formsRepo.updateForm(newForm); + + event.getHook().sendMessage("Form updated!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + handleFormIDAutocomplete(event, target); + } + +} 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..3221c906c --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java @@ -0,0 +1,91 @@ +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.data.config.BotConfig; +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.util.AutoCompleteUtils; + +/** + * The `/form remove-field` command. This command removes a field from the form. + * + * @see AddFieldFormSubcommand + * @see FormData + */ +public class RemoveFieldFormSubcommand extends FormSubcommand implements AutoCompletable { + + private static final String FORM_FIELD_INDEX_FIELD = "field"; + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public RemoveFieldFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("remove-field", "Remove a field from an existing form") + .addOption(OptionType.INTEGER, FORM_ID_FIELD, "Form ID to add the field to", true, true) + .addOption(OptionType.INTEGER, FORM_FIELD_INDEX_FIELD, "0-indexed # of the field to remove", true, true)); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + event.deferReply(true).queue(); + Optional formOpt = formsRepo.getForm(event.getOption(FORM_ID_FIELD, OptionMapping::getAsLong)); + int index = event.getOption(FORM_FIELD_INDEX_FIELD, OptionMapping::getAsInt); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + + if (form.getAttachmentInfo().isPresent() && 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; + } + + if (!formsRepo.removeField(form, index)) { + event.getHook().sendMessage("A field on this index was not found.").queue(); + return; + } + + event.getHook().sendMessage("Removed field `" + form.fields().get(index).label() + "` from the form.").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + if (!handleFormIDAutocomplete(event, target) && FORM_FIELD_INDEX_FIELD.equals(target.getName())) { + Long formId = event.getOption(FORM_ID_FIELD, 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(AutoCompleteUtils.filterChoices(event, choices)).queue(); + return; + } + } + } + } +} 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..c06147b70 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java @@ -0,0 +1,73 @@ +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.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; + +/** + * The `/form reopen` command. Reopens a closed form, allowing new submissions. + * + * @see CloseFormSubcommandr + * @see FormData + */ +public class ReopenFormSubcommand extends FormSubcommand 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) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + this.interactionManager = interactionManager; + setCommandData(new SubcommandData("reopen", "Reopen a closed form. This will allow new submissions.") + .addOptions(new OptionData(OptionType.INTEGER, FORM_ID_FIELD, "The ID of a closed form to reopen", true, + true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + long id = event.getOption(FORM_ID_FIELD, 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) { + handleFormIDAutocomplete(event, target); + } +} 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..f430cf64c --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java @@ -0,0 +1,62 @@ +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.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; + +/** + * The `/form show` command. Brings up an input modal for the given form. This + * command works even if the form is currently not accepting new submissions + * (due to being closed or expired), or is not attached to a message. + * + * @see FormData + */ +public class ShowFormSubcommand extends FormSubcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public ShowFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, 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_FIELD, "Form ID to add the field to", true, true)); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + Optional formOpt = formsRepo.getForm(event.getOption(FORM_ID_FIELD, 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.createSubmissionModal(form)).queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + handleFormIDAutocomplete(event, target); + } +} 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..c04acbf1f --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java @@ -0,0 +1,65 @@ +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.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.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; + +/** + * The `/form submissions-delete` command. Deletes all submission records from a + * given user from the database. For one-time forms this will allow a user who + * already submitted the form to submit it again. + * + * @see FormData + */ +public class SubmissionsDeleteFormSubcommand extends FormSubcommand implements AutoCompletable { + + private static final String FORM_USER_FIELD = "user"; + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param botConfig bot configuration + */ + public SubmissionsDeleteFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("submissions-delete", "Deletes submissions of a user in the form").addOptions( + new OptionData(OptionType.INTEGER, FORM_ID_FIELD, "The ID of a form to delete submissions from", true, true), + new OptionData(OptionType.USER, FORM_USER_FIELD, "User to delete submissions of", true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + event.deferReply().setEphemeral(true).queue(); + Optional formOpt = formsRepo.getForm(event.getOption(FORM_ID_FIELD, OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("Couldn't find a form with this id").queue(); + return; + } + + User user = event.getOption(FORM_USER_FIELD, 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) { + handleFormIDAutocomplete(event, target); + } +} 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..a75fae404 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java @@ -0,0 +1,83 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +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.data.config.BotConfig; +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.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; + +/** + * The `/form submissions-export` command. Export a list of users who have + * submitted the specified form from the database in JSON format. + * + * @see FormData + */ +public class SubmissionsExportFormSubcommand extends FormSubcommand 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 + * @param botConfig bot configuration + */ + public SubmissionsExportFormSubcommand(FormsRepository formsRepo, BotConfig botConfig) { + super(botConfig, formsRepo); + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("submissions-export", "Export all of the form's submissions") + .addOptions(new OptionData(OptionType.INTEGER, FORM_ID_FIELD, "The ID of a form to get submissions for", + true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + if (!checkForStaffRole(event)) return; + event.deferReply().setEphemeral(false).queue(); + Optional formOpt = formsRepo.getForm(event.getOption(FORM_ID_FIELD, 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(); + submissions.forEach((formUser, value) -> { + JsonObject uobj = new JsonObject(); + uobj.addProperty("username", formUser.username()); + uobj.addProperty("submissions", value); + details.add(Long.toString(formUser.id()), uobj); + users.add(formUser.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) { + handleFormIDAutocomplete(event, target); + } +} 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..6ead8d8ab --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -0,0 +1,309 @@ +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.FormAttachmentInfo; +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.components.textinput.TextInputStyle; +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 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, _) -> 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, _) -> 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, _) -> 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", (_, _) -> 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 -> { + + Optional attachmentInfoOptional = data.getAttachmentInfo(); + + 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, attachmentInfoOptional.map(FormAttachmentInfo::messageId).orElse(null)); + statement.setObject(5, attachmentInfoOptional.map(FormAttachmentInfo::messageChannelId).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 the user who submitted the form + * @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 and return false if the index is out of + * bounds. + * + * @param form form to remove the field from + * @param index index of the field to remove + * @return true if changes were successfully made to the database. + */ + public boolean removeField(FormData form, int index) { + List fields = form.fields(); + if (index < 0 || index >= fields.size()) return false; + return jdbcTemplate.update("delete from `form_fields` where `id` = ? and `form_id` = ?", fields.get(index).id(), + form.id()) > 0; + } + + /** + * 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, _) -> 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/FormAttachmentInfo.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormAttachmentInfo.java new file mode 100644 index 000000000..2bb3e75f5 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormAttachmentInfo.java @@ -0,0 +1,14 @@ +package net.discordjug.javabot.systems.staff_commands.forms.model; + +/** + * Contains information about form's attachment state. In other words, if the + * form is attached to a message, this records contains the message's and its + * channel IDs. + * + * @param messageId id of the message the form is attached to. + * @param messageChannelId id of the message's channel + * + * @see FormData + */ +public record FormAttachmentInfo(long messageId, long messageChannelId) { +} 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..fad77a730 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java @@ -0,0 +1,129 @@ +package net.discordjug.javabot.systems.staff_commands.forms.model; + +import java.time.Instant; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +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.components.label.Label; +import net.dv8tion.jda.api.modals.Modal; + +/** + * 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. + */ +public record FormData(long id, List fields, String title, long submitChannel, String submitMessage, + Long messageId, Long messageChannel, Instant expiration, boolean closed, boolean onetime) { + + /** + * The main constructor. + */ + public FormData { + Objects.requireNonNull(title); + fields = List.copyOf(fields); + if (fields.size() > Modal.MAX_COMPONENTS) { + throw new IllegalArgumentException("fields.size() > " + Modal.MAX_COMPONENTS); + } + } + + /** + * Get this form's submit message as an {@link Optional}. If the message is null + * or blank, the returned optional will be empty. + * + * @return optional submit message + */ + public Optional getOptionalSubmitMessage() { + if (submitMessage == null || submitMessage.isBlank()) { + return Optional.empty(); + } + return Optional.of(submitMessage); + } + + /** + * Get information about the form's attachment state. If the form is attached to + * a message, this method will return a non-empty optional containing + * information about the message this form is attached to. + * + * @return optional attachment info + */ + public Optional getAttachmentInfo() { + if (messageChannel != null && messageId != null) { + return Optional.of(new FormAttachmentInfo(messageId, messageChannel)); + } + return Optional.empty(); + } + + /** + * Creates text components for use in the submission modal. + * + * @return An unmodifiable list of layout components for use in the submission + * modal. + */ + public List