Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Add slash command to generate application form for various community roles #1049

Open
wants to merge 8 commits into
base: develop
Choose a base branch
from
7 changes: 7 additions & 0 deletions application/config.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -115,5 +115,12 @@
"fallbackChannelPattern": "java-news-and-changes",
"pollIntervalInMinutes": 10
},
"roleApplicationSystem": {
"submissionsChannelPattern": "staff-applications",
"defaultQuestion": "What makes you a good addition to the team?",
"minimumAnswerLength": 50,
"maximumAnswerLength": 500,
"applicationSubmitCooldownMinutes": 5
},
"memberCountCategoryPattern": "Info"
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public final class Config {
private final RSSFeedsConfig rssFeedsConfig;
private final String selectRolesChannelPattern;
private final String memberCountCategoryPattern;
private final RoleApplicationSystemConfig roleApplicationSystemConfig;

@SuppressWarnings("ConstructorWithTooManyParameters")
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
Expand Down Expand Up @@ -94,7 +95,9 @@ private Config(@JsonProperty(value = "token", required = true) String token,
required = true) FeatureBlacklistConfig featureBlacklistConfig,
@JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig,
@JsonProperty(value = "selectRolesChannelPattern",
required = true) String selectRolesChannelPattern) {
required = true) String selectRolesChannelPattern,
@JsonProperty(value = "roleApplicationSystem",
required = true) RoleApplicationSystemConfig roleApplicationSystemConfig) {
this.token = Objects.requireNonNull(token);
this.githubApiKey = Objects.requireNonNull(githubApiKey);
this.databasePath = Objects.requireNonNull(databasePath);
Expand Down Expand Up @@ -127,6 +130,7 @@ private Config(@JsonProperty(value = "token", required = true) String token,
this.featureBlacklistConfig = Objects.requireNonNull(featureBlacklistConfig);
this.rssFeedsConfig = Objects.requireNonNull(rssFeedsConfig);
this.selectRolesChannelPattern = Objects.requireNonNull(selectRolesChannelPattern);
this.roleApplicationSystemConfig = roleApplicationSystemConfig;
}

/**
Expand Down Expand Up @@ -410,6 +414,15 @@ public String getMemberCountCategoryPattern() {
return memberCountCategoryPattern;
}

/**
* The configuration related to the application form.
*
* @return the application form config
*/
public RoleApplicationSystemConfig getRoleApplicationSystemConfig() {
return roleApplicationSystemConfig;
}

/**
* Gets the RSS feeds configuration.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package org.togetherjava.tjbot.config;

import com.fasterxml.jackson.annotation.JsonProperty;
import net.dv8tion.jda.api.interactions.components.text.TextInput;

import java.util.Objects;

/**
* Represents the configuration for an application form, including roles and application channel
* pattern.
*
* @param submissionsChannelPattern the pattern used to identify the submissions channel where
* applications are sent
* @param defaultQuestion the default question that will be asked in the role application form
* @param minimumAnswerLength the minimum number of characters required for the applicant's answer
* @param maximumAnswerLength the maximum number of characters allowed for the applicant's answer
* @param applicationSubmitCooldownMinutes the cooldown time in minutes before the user can submit
* another application
*/
public record RoleApplicationSystemConfig(
tj-wazei marked this conversation as resolved.
Show resolved Hide resolved
@JsonProperty(value = "submissionsChannelPattern",
required = true) String submissionsChannelPattern,
@JsonProperty(value = "defaultQuestion", required = true) String defaultQuestion,
@JsonProperty(value = "minimumAnswerLength", required = true) int minimumAnswerLength,
@JsonProperty(value = "maximumAnswerLength", required = true) int maximumAnswerLength,
@JsonProperty(value = "applicationSubmitCooldownMinutes",
required = true) int applicationSubmitCooldownMinutes) {

/**
* Constructs an instance of {@link RoleApplicationSystemConfig} with the provided parameters.
* <p>
* This constructor ensures that {@code submissionsChannelPattern} and {@code defaultQuestion}
* are not null and that the length of the {@code defaultQuestion} does not exceed the maximum
* allowed length.
*/
public RoleApplicationSystemConfig {
Objects.requireNonNull(submissionsChannelPattern);
Objects.requireNonNull(defaultQuestion);

if (defaultQuestion.length() > TextInput.MAX_LABEL_LENGTH) {
throw new IllegalArgumentException(
"defaultQuestion length is too long! Cannot be greater than %d"
.formatted(TextInput.MAX_LABEL_LENGTH));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
import org.togetherjava.tjbot.features.moderation.temp.TemporaryModerationRoutine;
import org.togetherjava.tjbot.features.reminder.RemindRoutine;
import org.togetherjava.tjbot.features.reminder.ReminderCommand;
import org.togetherjava.tjbot.features.roleapplication.ApplicationCreateCommand;
import org.togetherjava.tjbot.features.system.BotCore;
import org.togetherjava.tjbot.features.system.LogLevelCommand;
import org.togetherjava.tjbot.features.tags.TagCommand;
Expand Down Expand Up @@ -192,6 +193,7 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
features.add(new BookmarksCommand(bookmarksSystem));
features.add(new ChatGptCommand(chatGptService, helpSystemHelper));
features.add(new JShellCommand(jshellEval));
features.add(new ApplicationCreateCommand(config));

FeatureBlacklist<Class<?>> blacklist = blacklistConfig.normal();
return blacklist.filterStream(features.stream(), Object::getClass).toList();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package org.togetherjava.tjbot.features.roleapplication;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent;
import net.dv8tion.jda.api.interactions.modals.ModalMapping;

import org.togetherjava.tjbot.config.RoleApplicationSystemConfig;

import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.regex.Pattern;

/**
* Handles the actual process of submitting role applications.
* <p>
* This class is responsible for managing application submissions via modal interactions, ensuring
* that submissions are sent to the appropriate application channel, and enforcing cooldowns for
* users to prevent spamming.
*/
public class ApplicationApplyHandler {
private final Cache<Member, OffsetDateTime> applicationSubmitCooldown;
private final Predicate<String> applicationChannelPattern;
private final RoleApplicationSystemConfig roleApplicationSystemConfig;

/**
* Constructs a new {@code ApplicationApplyHandler} instance.
*
* @param roleApplicationSystemConfig the configuration that contains the details for the
* application form including the cooldown duration and channel pattern.
*/
public ApplicationApplyHandler(RoleApplicationSystemConfig roleApplicationSystemConfig) {
this.roleApplicationSystemConfig = roleApplicationSystemConfig;
this.applicationChannelPattern =
Pattern.compile(roleApplicationSystemConfig.submissionsChannelPattern())
.asMatchPredicate();

final Duration applicationSubmitCooldownDuration =
Duration.ofMinutes(roleApplicationSystemConfig.applicationSubmitCooldownMinutes());
applicationSubmitCooldown =
Caffeine.newBuilder().expireAfterWrite(applicationSubmitCooldownDuration).build();
}

/**
* Sends the result of an application submission to the designated application channel in the
* guild.
* <p>
* The {@code args} parameter should contain the applicant's name and the role they are applying
* for.
*
* @param event the modal interaction event triggering the application submission
* @param args the arguments provided in the application submission
* @param answer the answer provided by the applicant to the default question
*/
protected void sendApplicationResult(final ModalInteractionEvent event, List<String> args,
String answer) {
Guild guild = event.getGuild();
if (args.size() != 2 || guild == null) {
return;
}

Optional<TextChannel> applicationChannel = getApplicationChannel(guild);
if (applicationChannel.isEmpty()) {
return;
}

User applicant = event.getUser();
EmbedBuilder embed =
new EmbedBuilder().setAuthor(applicant.getName(), null, applicant.getAvatarUrl())
.setColor(ApplicationCreateCommand.AMBIENT_COLOR)
.setTimestamp(Instant.now())
.setFooter("Submitted at");

String roleString = args.getLast();
MessageEmbed.Field roleField = new MessageEmbed.Field("Role", roleString, false);
embed.addField(roleField);

MessageEmbed.Field answerField = new MessageEmbed.Field(
roleApplicationSystemConfig.defaultQuestion(), answer, false);
embed.addField(answerField);

applicationChannel.get().sendMessageEmbeds(embed.build()).queue();
}

/**
* Retrieves the application channel from the given {@link Guild}.
*
* @param guild the guild from which to retrieve the application channel
* @return an {@link Optional} containing the {@link TextChannel} representing the application
* channel, or an empty {@link Optional} if no such channel is found
*/
private Optional<TextChannel> getApplicationChannel(Guild guild) {
return guild.getChannels()
.stream()
.filter(channel -> applicationChannelPattern.test(channel.getName()))
.filter(channel -> channel.getType().isMessage())
.map(TextChannel.class::cast)
.findFirst();
}

public Cache<Member, OffsetDateTime> getApplicationSubmitCooldown() {
return applicationSubmitCooldown;
}

protected void submitApplicationFromModalInteraction(ModalInteractionEvent event,
List<String> args) {
Guild guild = event.getGuild();

if (guild == null) {
return;
}

ModalMapping modalAnswer = event.getValues().getFirst();

sendApplicationResult(event, args, modalAnswer.getAsString());
event.reply("Your application has been submitted. Thank you for applying! 😎")
.setEphemeral(true)
.queue();

applicationSubmitCooldown.put(event.getMember(), OffsetDateTime.now());
}

protected long getMemberCooldownMinutes(Member member) {
OffsetDateTime timeSentCache = getApplicationSubmitCooldown().getIfPresent(member);
if (timeSentCache != null) {
Duration duration = Duration.between(timeSentCache, OffsetDateTime.now());
return roleApplicationSystemConfig.applicationSubmitCooldownMinutes()
- duration.toMinutes();
}
return 0L;
}
}
Loading
Loading