Skip to content

Commit 0f818bf

Browse files
authored
Merge pull request #524 from Defective4/forms-feature
Forms system
2 parents 597abbd + 0556dda commit 0f818bf

22 files changed

+2226
-0
lines changed
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
package net.discordjug.javabot.systems.staff_commands.forms;
2+
3+
import java.time.Instant;
4+
import java.time.LocalDateTime;
5+
import java.time.ZoneOffset;
6+
import java.time.format.DateTimeFormatter;
7+
import java.time.format.DateTimeParseException;
8+
import java.util.List;
9+
import java.util.Objects;
10+
import java.util.Optional;
11+
import java.util.function.Function;
12+
13+
import lombok.RequiredArgsConstructor;
14+
import lombok.extern.slf4j.Slf4j;
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.discordjug.javabot.util.ExceptionLogger;
20+
import net.discordjug.javabot.util.Responses;
21+
import net.dv8tion.jda.api.EmbedBuilder;
22+
import net.dv8tion.jda.api.components.actionrow.ActionRow;
23+
import net.dv8tion.jda.api.components.actionrow.ActionRowChildComponent;
24+
import net.dv8tion.jda.api.components.buttons.Button;
25+
import net.dv8tion.jda.api.entities.Guild;
26+
import net.dv8tion.jda.api.entities.Member;
27+
import net.dv8tion.jda.api.entities.Message;
28+
import net.dv8tion.jda.api.entities.MessageEmbed;
29+
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
30+
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
31+
import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent;
32+
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
33+
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
34+
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
35+
import net.dv8tion.jda.api.interactions.modals.ModalMapping;
36+
import net.dv8tion.jda.api.modals.Modal;
37+
import xyz.dynxsty.dih4jda.interactions.components.ButtonHandler;
38+
import xyz.dynxsty.dih4jda.interactions.components.ModalHandler;
39+
import xyz.dynxsty.dih4jda.util.ComponentIdBuilder;
40+
41+
/**
42+
* Handle forms interactions, including buttons and submissions modals.
43+
*/
44+
@AutoDetectableComponentHandler(FormInteractionManager.FORM_COMPONENT_ID)
45+
@RequiredArgsConstructor
46+
@Slf4j
47+
public class FormInteractionManager implements ButtonHandler, ModalHandler {
48+
49+
/**
50+
* String representation of the date and time format used in forms.
51+
*/
52+
public static final String DATE_FORMAT_STRING = "dd.MM.yyyy HH:mm";
53+
54+
/**
55+
* Date and time formatter used in forms.
56+
*/
57+
public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(DATE_FORMAT_STRING);
58+
59+
/**
60+
* Component ID used for form buttons and modals.
61+
*/
62+
public static final String FORM_COMPONENT_ID = "modal-form";
63+
64+
private static final String SUBMISSION_ERROR_LOG = "A user tried to submit a form \"%s\", but an error occured.";
65+
66+
private static final String SUBMISSION_ERROR_MSG = "We couldn't receive your submission due to an error. Please contact server staff.";
67+
68+
private static final String FORM_NOT_FOUND_MSG = "This form was not found in the database. Please report this to the server staff.";
69+
70+
private final FormsRepository formsRepo;
71+
72+
/**
73+
* Closes the form, preventing further submissions and disabling associated
74+
* buttons from a message this form is attached to, if any.
75+
*
76+
* @param guild guild this form is located in.
77+
* @param form form to close.
78+
*/
79+
public void closeForm(Guild guild, FormData form) {
80+
formsRepo.closeForm(form);
81+
82+
form.getAttachmentInfo().ifPresent(info -> {
83+
long messageChannelId = info.messageChannelId();
84+
long messageId = info.messageId();
85+
MessageChannel formChannel = guild.getJDA().getChannelById(MessageChannel.class, messageChannelId);
86+
formChannel.retrieveMessageById(messageId).queue(msg -> {
87+
editFormMessageButtons(msg, btn -> {
88+
String cptId = btn.getCustomId();
89+
String[] split = ComponentIdBuilder.split(cptId);
90+
if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID)
91+
&& split[1].equals(Long.toString(form.id()))) {
92+
return btn.asDisabled();
93+
}
94+
return btn;
95+
});
96+
}, ExceptionLogger::capture);
97+
});
98+
}
99+
100+
@Override
101+
public void handleButton(ButtonInteractionEvent event, Button button) {
102+
long formId = Long.parseLong(ComponentIdBuilder.split(button.getCustomId())[1]);
103+
Optional<FormData> formOpt = formsRepo.getForm(formId);
104+
if (!formOpt.isPresent()) {
105+
Responses.error(event, FORM_NOT_FOUND_MSG).queue();
106+
return;
107+
}
108+
FormData form = formOpt.get();
109+
if (!isOpen(form)) {
110+
event.reply("This form is not accepting new submissions.").setEphemeral(true).queue();
111+
if (!form.closed()) {
112+
closeForm(event.getGuild(), form);
113+
}
114+
return;
115+
}
116+
117+
if (form.onetime() && formsRepo.hasSubmitted(event.getUser(), form)) {
118+
event.reply("You have already submitted this form").setEphemeral(true).queue();
119+
return;
120+
}
121+
122+
Modal modal = createSubmissionModal(form);
123+
124+
event.replyModal(modal).queue();
125+
}
126+
127+
@Override
128+
public void handleModal(ModalInteractionEvent event, List<ModalMapping> values) {
129+
event.deferReply().setEphemeral(true).queue();
130+
long formId = Long.parseLong(ComponentIdBuilder.split(event.getModalId())[1]);
131+
Optional<FormData> formOpt = formsRepo.getForm(formId);
132+
if (!formOpt.isPresent()) {
133+
event.reply(FORM_NOT_FOUND_MSG).setEphemeral(true).queue();
134+
return;
135+
}
136+
137+
FormData form = formOpt.get();
138+
139+
if (!isOpen(form)) {
140+
event.getHook().sendMessage("This form is not accepting new submissions.").queue();
141+
return;
142+
}
143+
144+
if (form.onetime() && formsRepo.hasSubmitted(event.getUser(), form)) {
145+
event.getHook().sendMessage("You have already submitted this form").queue();
146+
return;
147+
}
148+
149+
TextChannel channel = event.getGuild().getTextChannelById(form.submitChannel());
150+
if (channel == null) {
151+
log.warn("A user tried to submit a form \"%s\" because the submission channel does not exist."
152+
.formatted(form.title()));
153+
event.getHook().sendMessage(SUBMISSION_ERROR_MSG).queue();
154+
return;
155+
}
156+
157+
try {
158+
channel.sendMessageEmbeds(createSubmissionEmbed(form, values, event.getMember())).queue(msg -> {
159+
formsRepo.addSubmission(event.getUser(), form, msg);
160+
event.getHook().sendMessage(form.getOptionalSubmitMessage().orElse("Your submission was received!"))
161+
.queue();
162+
}, e -> {
163+
event.getHook().sendMessage(SUBMISSION_ERROR_MSG).queue();
164+
log.error(SUBMISSION_ERROR_LOG.formatted(form.title()));
165+
ExceptionLogger.capture(e);
166+
});
167+
} catch (IllegalArgumentException e) {
168+
event.getHook().sendMessage(SUBMISSION_ERROR_MSG).queue();
169+
log.error(SUBMISSION_ERROR_LOG.formatted(form.title()));
170+
ExceptionLogger.capture(e);
171+
}
172+
}
173+
174+
/**
175+
* Modifies buttons in a message using given function for mapping.
176+
*
177+
* @param msg message to modify buttons in.
178+
* @param editFunction function to edit the buttons.
179+
*/
180+
public void editFormMessageButtons(Message msg, Function<Button, Button> editFunction) {
181+
List<ActionRow> components = msg.getComponents().stream().map(messageComponent -> {
182+
ActionRow row = messageComponent.asActionRow();
183+
List<ActionRowChildComponent> cpts = row.getComponents().stream().map(cpt -> {
184+
if (cpt instanceof Button btn) {
185+
return editFunction.apply(btn);
186+
}
187+
return cpt;
188+
}).toList();
189+
if (cpts.isEmpty()) {
190+
return null;
191+
}
192+
return ActionRow.of(cpts);
193+
}).filter(Objects::nonNull).toList();
194+
msg.editMessageComponents(components).queue();
195+
}
196+
197+
/**
198+
* Re-opens the form, re-enabling associated buttons in the message it's
199+
* attached to, if any.
200+
*
201+
* @param guild guild this form is contained in.
202+
* @param form form to re-open.
203+
*/
204+
public void reopenForm(Guild guild, FormData form) {
205+
formsRepo.reopenForm(form);
206+
207+
form.getAttachmentInfo().ifPresent(info -> {
208+
long messageChannelId = info.messageChannelId();
209+
long messageId = info.messageId();
210+
TextChannel formChannel = guild.getTextChannelById(messageChannelId);
211+
formChannel.retrieveMessageById(messageId).queue(msg -> {
212+
editFormMessageButtons(msg, btn -> {
213+
String cptId = btn.getCustomId();
214+
String[] split = ComponentIdBuilder.split(cptId);
215+
if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID)
216+
&& split[1].equals(Long.toString(form.id()))) {
217+
return btn.asEnabled();
218+
}
219+
return btn;
220+
});
221+
}, ExceptionLogger::capture);
222+
});
223+
}
224+
225+
/**
226+
* Creates a submission modal for the given form.
227+
*
228+
* @param form form to open submission modal for.
229+
* @return submission modal to be presented to the user.
230+
*/
231+
public static Modal createSubmissionModal(FormData form) {
232+
Modal modal = Modal.create(ComponentIdBuilder.build(FORM_COMPONENT_ID, form.id()), form.title())
233+
.addComponents(form.createComponents()).build();
234+
return modal;
235+
}
236+
237+
/**
238+
* Gets expiration time from the slash comamnd event.
239+
*
240+
* @param event slash event to get expiration from.
241+
* @return an optional containing expiration time, or an empty optional if it's
242+
* not present.
243+
* @throws IllegalArgumentException if the date doesn't follow the format.
244+
*/
245+
public static Optional<Instant> parseExpiration(SlashCommandInteractionEvent event)
246+
throws IllegalArgumentException {
247+
String expirationStr = event.getOption("expiration", null, OptionMapping::getAsString);
248+
Optional<Instant> expiration;
249+
if (expirationStr == null) {
250+
expiration = Optional.empty();
251+
} else {
252+
try {
253+
expiration = Optional.of(LocalDateTime.parse(expirationStr, DATE_FORMATTER).toInstant(ZoneOffset.UTC));
254+
} catch (DateTimeParseException e) {
255+
throw new IllegalArgumentException("Invalid date. You should follow the format `"
256+
+ FormInteractionManager.DATE_FORMAT_STRING + "`.");
257+
}
258+
}
259+
260+
if (expiration.isPresent() && expiration.get().isBefore(Instant.now())) {
261+
throw new IllegalArgumentException("The expiration date shouldn't be in the past");
262+
}
263+
return expiration;
264+
}
265+
266+
private static boolean isOpen(FormData data) {
267+
if (data.closed() || data.hasExpired()) {
268+
return false;
269+
}
270+
271+
return true;
272+
}
273+
274+
private static MessageEmbed createSubmissionEmbed(FormData form, List<ModalMapping> values, Member author) {
275+
EmbedBuilder builder = new EmbedBuilder().setTitle("New form submission received")
276+
.setAuthor(author.getEffectiveName(), null, author.getEffectiveAvatarUrl()).setTimestamp(Instant.now());
277+
builder.addField("Sender", String.format("%s (`%s`)", author.getAsMention(), author.getId()), true)
278+
.addField("Title", form.title(), true);
279+
280+
int len = Math.min(values.size(), form.fields().size());
281+
for (int i = 0; i < len; i++) {
282+
ModalMapping mapping = values.get(i);
283+
FormField field = form.fields().get(i);
284+
String value = mapping.getAsString();
285+
if (value != null) value = value.replace("```", "` ` `");
286+
builder.addField(field.label(), value == null ? "*Empty*" : "```\n" + value + "\n```", false);
287+
}
288+
289+
return builder.build();
290+
}
291+
}

0 commit comments

Comments
 (0)