Skip to content

Commit a114e30

Browse files
committed
Forms system
A command to attach form buttons to bot's messages Form data deserialization Store forms in database Use an array of form fields instead of a form data object Move `FormsRepository` and `FormField` to different packages Reading forms from database Open modals on form button interaction Log submissions in a configured channel Allow setting a custom submission message Use an *Empty* value if user ommits a field Ignore case when parsing form field text input styles Store forms' origin messages, their channels, and expiration date Form delete command autocompletion Form delete command Forms closing and reopening commands Form details command Form modification command Check if the form is not closed or expired on submission Reword expiration command parameter description Additional checks in all form commands Don't accept raw JSON data and don't immediately attach forms Added `add-field` form command Form field remove command Form show command Put a limit of 5 components for forms Allow field inserting Forms attach and detach commands Prevent removing the last field from an attached form Javadocs Remove redundant checks from form commands Move some common methods to the form interaction manager Added `hasExpired` method to `FormData` Add `onetime` form parameter Logging form submissions to the database Add a repository method to query forms for a specific `closed` state Include total submissions count in the form details message Additional null checks in the forms repository Prevent further submissions to one-time forms A command to export all form submissions Delete form submissions on form deletion Command to delete user's submissions Fix checkstyle violations
1 parent 6a87f3d commit a114e30

20 files changed

+2095
-0
lines changed
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
package net.discordjug.javabot.systems.staff_commands.forms;
2+
3+
import java.text.DateFormat;
4+
import java.text.ParseException;
5+
import java.text.SimpleDateFormat;
6+
import java.time.Instant;
7+
import java.util.List;
8+
import java.util.Locale;
9+
import java.util.Objects;
10+
import java.util.Optional;
11+
import java.util.TimeZone;
12+
import java.util.function.Function;
13+
14+
import lombok.RequiredArgsConstructor;
15+
import net.discordjug.javabot.annotations.AutoDetectableComponentHandler;
16+
import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository;
17+
import net.discordjug.javabot.systems.staff_commands.forms.model.FormData;
18+
import net.discordjug.javabot.systems.staff_commands.forms.model.FormField;
19+
import net.dv8tion.jda.api.EmbedBuilder;
20+
import net.dv8tion.jda.api.entities.Guild;
21+
import net.dv8tion.jda.api.entities.Member;
22+
import net.dv8tion.jda.api.entities.Message;
23+
import net.dv8tion.jda.api.entities.MessageEmbed;
24+
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
25+
import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent;
26+
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
27+
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
28+
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
29+
import net.dv8tion.jda.api.interactions.components.ActionRow;
30+
import net.dv8tion.jda.api.interactions.components.ItemComponent;
31+
import net.dv8tion.jda.api.interactions.components.buttons.Button;
32+
import net.dv8tion.jda.api.interactions.modals.Modal;
33+
import net.dv8tion.jda.api.interactions.modals.ModalMapping;
34+
import xyz.dynxsty.dih4jda.interactions.components.ButtonHandler;
35+
import xyz.dynxsty.dih4jda.interactions.components.ModalHandler;
36+
import xyz.dynxsty.dih4jda.util.ComponentIdBuilder;
37+
38+
/**
39+
* Handle forms interactions, including buttons and submissions modals.
40+
*/
41+
@AutoDetectableComponentHandler(FormInteractionManager.FORM_COMPONENT_ID)
42+
@RequiredArgsConstructor
43+
public class FormInteractionManager implements ButtonHandler, ModalHandler {
44+
45+
/**
46+
* Date and time format used in forms.
47+
*/
48+
public static final DateFormat DATE_FORMAT;
49+
50+
/**
51+
* String representation of the date and time format used in forms.
52+
*/
53+
public static final String DATE_FORMAT_STRING;
54+
55+
/**
56+
* Component ID used for form buttons and modals.
57+
*/
58+
public static final String FORM_COMPONENT_ID = "modal-form";
59+
private static final String FORM_NOT_FOUND_MSG = "This form was not found in the database. Please report this to the server staff.";
60+
61+
private final FormsRepository formsRepo;
62+
63+
static {
64+
DATE_FORMAT_STRING = "dd/MM/yyyy HH:mm";
65+
DATE_FORMAT = new SimpleDateFormat(FormInteractionManager.DATE_FORMAT_STRING, Locale.ENGLISH);
66+
DATE_FORMAT.setTimeZone(TimeZone.getTimeZone("UTC"));
67+
}
68+
69+
/**
70+
* Closes the form, preventing further submissions and disabling associated
71+
* buttons from a message this form is attached to, if any.
72+
*
73+
* @param guild guild this form is located in.
74+
* @param form form to close.
75+
*/
76+
public void closeForm(Guild guild, FormData form) {
77+
formsRepo.closeForm(form);
78+
79+
if (form.getMessageChannel() != null && form.getMessageId() != null) {
80+
TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel());
81+
formChannel.retrieveMessageById(form.getMessageId()).queue(msg -> {
82+
mapFormMessageButtons(msg, btn -> {
83+
String cptId = btn.getId();
84+
String[] split = ComponentIdBuilder.split(cptId);
85+
if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID)
86+
&& split[1].equals(Long.toString(form.getId()))) {
87+
return btn.asDisabled();
88+
}
89+
return btn;
90+
});
91+
}, t -> {});
92+
}
93+
}
94+
95+
@Override
96+
public void handleButton(ButtonInteractionEvent event, Button button) {
97+
long formId = Long.parseLong(ComponentIdBuilder.split(button.getId())[1]);
98+
Optional<FormData> formOpt = formsRepo.getForm(formId);
99+
if (!formOpt.isPresent()) {
100+
event.reply(FORM_NOT_FOUND_MSG).setEphemeral(true).queue();
101+
return;
102+
}
103+
FormData form = formOpt.get();
104+
if (!checkNotClosed(form)) {
105+
event.reply("This form is not accepting new submissions.").setEphemeral(true).queue();
106+
if (!form.isClosed()) {
107+
closeForm(event.getGuild(), form);
108+
}
109+
return;
110+
}
111+
112+
if (form.isOnetime() && formsRepo.hasSubmitted(event.getUser(), form)) {
113+
event.reply("You have already submitted this form").setEphemeral(true).queue();
114+
return;
115+
}
116+
117+
Modal modal = createFormModal(form);
118+
119+
event.replyModal(modal).queue();
120+
}
121+
122+
@Override
123+
public void handleModal(ModalInteractionEvent event, List<ModalMapping> values) {
124+
event.deferReply().setEphemeral(true).queue();
125+
long formId = Long.parseLong(ComponentIdBuilder.split(event.getModalId())[1]);
126+
Optional<FormData> formOpt = formsRepo.getForm(formId);
127+
if (!formOpt.isPresent()) {
128+
event.reply(FORM_NOT_FOUND_MSG).setEphemeral(true).queue();
129+
return;
130+
}
131+
132+
FormData form = formOpt.get();
133+
134+
if (!checkNotClosed(form)) {
135+
event.getHook().sendMessage("This form is not accepting new submissions.").queue();
136+
return;
137+
}
138+
139+
if (form.isOnetime() && formsRepo.hasSubmitted(event.getUser(), form)) {
140+
event.getHook().sendMessage("You have already submitted this form").queue();
141+
return;
142+
}
143+
144+
TextChannel channel = event.getGuild().getTextChannelById(form.getSubmitChannel());
145+
if (channel == null) {
146+
event.getHook()
147+
.sendMessage("We couldn't receive your submission due to an error. Please contact server staff.")
148+
.queue();
149+
return;
150+
}
151+
152+
channel.sendMessageEmbeds(createSubmissionEmbed(form, values, event.getMember())).queue();
153+
formsRepo.logSubmission(event.getUser(), form);
154+
155+
event.getHook()
156+
.sendMessage(
157+
form.getSubmitMessage() == null ? "Your submission was received!" : form.getSubmitMessage())
158+
.queue();
159+
}
160+
161+
/**
162+
* Modifies buttons in a message using given function for mapping.
163+
*
164+
* @param msg message to modify buttons in.
165+
* @param mapper mapping function.
166+
*/
167+
public void mapFormMessageButtons(Message msg, Function<Button, Button> mapper) {
168+
List<ActionRow> components = msg.getActionRows().stream().map(row -> {
169+
ItemComponent[] cpts = row.getComponents().stream().map(cpt -> {
170+
if (cpt instanceof Button btn) {
171+
return mapper.apply(btn);
172+
}
173+
return cpt;
174+
}).toList().toArray(new ItemComponent[0]);
175+
if (cpts.length == 0) {
176+
return null;
177+
}
178+
return ActionRow.of(cpts);
179+
}).filter(Objects::nonNull).toList();
180+
msg.editMessageComponents(components).queue();
181+
}
182+
183+
/**
184+
* Re-opens the form, re-enabling associated buttons in message it's attached
185+
* to, if any.
186+
*
187+
* @param guild guild this form is contained in.
188+
* @param form form to re-open.
189+
*/
190+
public void reopenForm(Guild guild, FormData form) {
191+
formsRepo.reopenForm(form);
192+
193+
if (form.getMessageChannel() != null && form.getMessageId() != null) {
194+
TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel());
195+
formChannel.retrieveMessageById(form.getMessageId()).queue(msg -> {
196+
mapFormMessageButtons(msg, btn -> {
197+
String cptId = btn.getId();
198+
String[] split = ComponentIdBuilder.split(cptId);
199+
if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID)
200+
&& split[1].equals(Long.toString(form.getId()))) {
201+
return btn.asEnabled();
202+
}
203+
return btn;
204+
});
205+
}, t -> {});
206+
}
207+
}
208+
209+
/**
210+
* Creates a submission modal for the given form.
211+
*
212+
* @param form form to open submission modal for.
213+
* @return submission modal to be presented to the user.
214+
*/
215+
public static Modal createFormModal(FormData form) {
216+
Modal modal = Modal.create(ComponentIdBuilder.build(FORM_COMPONENT_ID, form.getId()), form.getTitle())
217+
.addComponents(form.createComponents()).build();
218+
return modal;
219+
}
220+
221+
/**
222+
* Gets expiration time from the slash comamnd event.
223+
*
224+
* @param event slash event to get expiration from.
225+
* @return an optional containing expiration time,
226+
* {@link FormData#EXPIRATION_PERMANENT} if none given, or an empty
227+
* optional if it's invalid.
228+
*/
229+
public static Optional<Long> parseExpiration(SlashCommandInteractionEvent event) {
230+
String expirationStr = event.getOption("expiration", null, OptionMapping::getAsString);
231+
Optional<Long> expiration;
232+
if (expirationStr == null) {
233+
expiration = Optional.of(FormData.EXPIRATION_PERMANENT);
234+
} else {
235+
try {
236+
expiration = Optional.of(FormInteractionManager.DATE_FORMAT.parse(expirationStr).getTime());
237+
} catch (ParseException e) {
238+
event.getHook().sendMessage("Invalid date. You should follow the format `"
239+
+ FormInteractionManager.DATE_FORMAT_STRING + "`.").setEphemeral(true).queue();
240+
expiration = Optional.empty();
241+
}
242+
}
243+
244+
if (expiration.isPresent() && expiration.get() != FormData.EXPIRATION_PERMANENT
245+
&& expiration.get() < System.currentTimeMillis()) {
246+
event.getHook().sendMessage("The expiration date shouldn't be in the past").setEphemeral(true).queue();
247+
return Optional.empty();
248+
}
249+
return expiration;
250+
}
251+
252+
private static boolean checkNotClosed(FormData data) {
253+
if (data.isClosed() || data.hasExpired()) {
254+
return false;
255+
}
256+
257+
return true;
258+
}
259+
260+
private static MessageEmbed createSubmissionEmbed(FormData form, List<ModalMapping> values, Member author) {
261+
EmbedBuilder builder = new EmbedBuilder().setTitle("New form submission received")
262+
.setAuthor(author.getEffectiveName(), null, author.getEffectiveAvatarUrl()).setTimestamp(Instant.now());
263+
builder.addField("Sender", author.getAsMention(), true).addField("Title", form.getTitle(), true);
264+
265+
int len = Math.min(values.size(), form.getFields().size());
266+
for (int i = 0; i < len; i++) {
267+
ModalMapping mapping = values.get(i);
268+
FormField field = form.getFields().get(i);
269+
String value = mapping.getAsString();
270+
builder.addField(field.getLabel(), value == null ? "*Empty*" : "```\n" + value + "\n```", false);
271+
}
272+
273+
return builder.build();
274+
}
275+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package net.discordjug.javabot.systems.staff_commands.forms.commands;
2+
3+
import java.util.Arrays;
4+
import java.util.Optional;
5+
6+
import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository;
7+
import net.discordjug.javabot.systems.staff_commands.forms.model.FormData;
8+
import net.discordjug.javabot.systems.staff_commands.forms.model.FormField;
9+
import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent;
10+
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
11+
import net.dv8tion.jda.api.interactions.AutoCompleteQuery;
12+
import net.dv8tion.jda.api.interactions.commands.Command.Choice;
13+
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
14+
import net.dv8tion.jda.api.interactions.commands.OptionType;
15+
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
16+
import net.dv8tion.jda.api.interactions.components.text.TextInputStyle;
17+
import xyz.dynxsty.dih4jda.interactions.AutoCompletable;
18+
import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand;
19+
20+
/**
21+
* The `/form add-field` command.
22+
*/
23+
public class AddFieldFormSubcommand extends Subcommand implements AutoCompletable {
24+
25+
private final FormsRepository formsRepo;
26+
27+
/**
28+
* The main constructor of this subcommand.
29+
*
30+
* @param formsRepo the forms repository
31+
*/
32+
public AddFieldFormSubcommand(FormsRepository formsRepo) {
33+
this.formsRepo = formsRepo;
34+
setCommandData(new SubcommandData("add-field", "Adds a field to an existing form")
35+
.addOption(OptionType.INTEGER, "form-id", "Form ID to add the field to", true, true)
36+
.addOption(OptionType.STRING, "label", "Field label", true)
37+
.addOption(OptionType.INTEGER, "min", "Minimum number of characters")
38+
.addOption(OptionType.INTEGER, "max", "Maximum number of characters")
39+
.addOption(OptionType.STRING, "placeholder", "Field placeholder")
40+
.addOption(OptionType.BOOLEAN, "required",
41+
"Whether or not the user has to input data in this field. Default: false")
42+
.addOption(OptionType.STRING, "style", "Input style. Default: SHORT", false, true)
43+
.addOption(OptionType.STRING, "value", "Initial field value")
44+
.addOption(OptionType.INTEGER, "index", "Index to insert the field at"));
45+
}
46+
47+
@Override
48+
public void execute(SlashCommandInteractionEvent event) {
49+
50+
event.deferReply(true).queue();
51+
Optional<FormData> formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong));
52+
if (formOpt.isEmpty()) {
53+
event.getHook().sendMessage("A form with this ID was not found.").queue();
54+
return;
55+
}
56+
FormData form = formOpt.get();
57+
58+
if (form.getFields().size() >= 5) {
59+
event.getHook().sendMessage("Can't add more than 5 components to a form").queue();
60+
return;
61+
}
62+
63+
int index = event.getOption("index", -1, OptionMapping::getAsInt);
64+
if (index < -1 || index >= form.getFields().size()) {
65+
event.getHook().sendMessage("Field index out of bounds").queue();
66+
return;
67+
}
68+
69+
formsRepo.addField(form, createFormFieldFromEvent(event), index);
70+
event.getHook().sendMessage("Added a new field to the form.").queue();
71+
}
72+
73+
@Override
74+
public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) {
75+
switch (target.getName()) {
76+
case "form-id" -> event.replyChoices(
77+
formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList())
78+
.queue();
79+
case "style" ->
80+
event.replyChoices(Arrays.stream(TextInputStyle.values()).filter(t -> t != TextInputStyle.UNKNOWN)
81+
.map(style -> new Choice(style.name(), style.name())).toList()).queue();
82+
default -> {}
83+
}
84+
}
85+
86+
private static FormField createFormFieldFromEvent(SlashCommandInteractionEvent e) {
87+
String label = e.getOption("label", OptionMapping::getAsString);
88+
int min = e.getOption("min", 0, OptionMapping::getAsInt);
89+
int max = e.getOption("max", 64, OptionMapping::getAsInt);
90+
String placeholder = e.getOption("placeholder", OptionMapping::getAsString);
91+
boolean required = e.getOption("required", false, OptionMapping::getAsBoolean);
92+
TextInputStyle style = e.getOption("style", TextInputStyle.SHORT, t -> {
93+
try {
94+
return TextInputStyle.valueOf(t.getAsString().toUpperCase());
95+
} catch (IllegalArgumentException e2) {
96+
return TextInputStyle.SHORT;
97+
}
98+
});
99+
String value = e.getOption("value", OptionMapping::getAsString);
100+
101+
return new FormField(label, max, min, placeholder, required, style.name(), value);
102+
}
103+
}

0 commit comments

Comments
 (0)