-
-
Notifications
You must be signed in to change notification settings - Fork 91
Feat: Add slash command to generate application form for various community roles #1049
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
Open
christolis
wants to merge
23
commits into
Together-Java:develop
Choose a base branch
from
christolis:feature/application-form
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+518
−1
Open
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
6d8ceee
feat: add role application system config
christolis a42a428
feat: create role application command
christolis dcb241c
fix(handle-permissions): remove unnecessary check
christolis 375ae45
refactor: make cooldown minutes check into separate method
christolis 8651807
feat: move substring of label into config record
christolis 0a04d90
docs: rewrite documentation for `RoleApplicationSystemConfig`
christolis 8ab2f40
style: run spotlessApply gradle task
christolis f46c983
feat(cooldown): use the proper word depending on count
christolis 3fb788a
application-create: hardcode certain config values
christolis c51c9ff
refactor: rename to CreateRoleApplicationCommand
christolis ee96742
refactor: rename to RoleApplicationHandler
christolis b6db802
refactor: simplify variable naming
christolis 51e3149
generateRoleOptions: remove unnecessary parameter
christolis 81b6557
refactor: make consants more verbose and precise
christolis 83df552
refactor: use OPTIONS_PER_ROLE where needed
christolis 4690d67
refactor: soften up application message
christolis 98cdec5
refactor: remove unneeded word plural helper method
christolis 77adc44
refactor: move `correctMinutesWord` inside block
christolis 643d600
refactor: move `setTimestamp()` after `setFooter()`
christolis 1507cc1
refactor: handle `roleString` from early on
christolis c71ec52
refactor(application-form): better exception handling
christolis c4aeb1a
refactor(application-form): reduce visibility
christolis 4d2673d
log(application-command): handle null cases
christolis File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
38 changes: 38 additions & 0 deletions
38
application/src/main/java/org/togetherjava/tjbot/config/RoleApplicationSystemConfig.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
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 | ||
*/ | ||
public record RoleApplicationSystemConfig( | ||
@JsonProperty(value = "submissionsChannelPattern", | ||
required = true) String submissionsChannelPattern, | ||
@JsonProperty(value = "defaultQuestion", required = true) String defaultQuestion) { | ||
|
||
/** | ||
* 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)); | ||
} | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
286 changes: 286 additions & 0 deletions
286
...in/java/org/togetherjava/tjbot/features/roleapplication/CreateRoleApplicationCommand.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,286 @@ | ||
package org.togetherjava.tjbot.features.roleapplication; | ||
|
||
import net.dv8tion.jda.api.EmbedBuilder; | ||
import net.dv8tion.jda.api.Permission; | ||
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.emoji.Emoji; | ||
import net.dv8tion.jda.api.entities.emoji.EmojiUnion; | ||
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.StringSelectInteractionEvent; | ||
import net.dv8tion.jda.api.interactions.commands.CommandInteraction; | ||
import net.dv8tion.jda.api.interactions.commands.OptionMapping; | ||
import net.dv8tion.jda.api.interactions.commands.OptionType; | ||
import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; | ||
import net.dv8tion.jda.api.interactions.components.ActionRow; | ||
import net.dv8tion.jda.api.interactions.components.selections.SelectOption; | ||
import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu; | ||
import net.dv8tion.jda.api.interactions.components.text.TextInput; | ||
import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; | ||
import net.dv8tion.jda.api.interactions.modals.Modal; | ||
import org.slf4j.Logger; | ||
import org.slf4j.LoggerFactory; | ||
|
||
import org.togetherjava.tjbot.config.Config; | ||
import org.togetherjava.tjbot.config.RoleApplicationSystemConfig; | ||
import org.togetherjava.tjbot.features.CommandVisibility; | ||
import org.togetherjava.tjbot.features.SlashCommandAdapter; | ||
import org.togetherjava.tjbot.features.componentids.Lifespan; | ||
|
||
import javax.annotation.Nullable; | ||
|
||
import java.awt.Color; | ||
import java.util.HashMap; | ||
import java.util.List; | ||
import java.util.Map; | ||
import java.util.stream.IntStream; | ||
|
||
/** | ||
* Represents a command to create an application form for members to apply for roles. | ||
* <p> | ||
* This command is designed to generate an application form for members to apply for roles within a | ||
* guild. | ||
*/ | ||
public class CreateRoleApplicationCommand extends SlashCommandAdapter { | ||
protected static final Color AMBIENT_COLOR = new Color(24, 221, 136, 255); | ||
private static final int OPTIONAL_ROLES_AMOUNT = 5; | ||
private static final String ROLE_COMPONENT_ID_HEADER = "application-create"; | ||
private static final String OPTION_PARAM_ID_DELIMITER = "_"; | ||
private static final int OPTIONS_PER_ROLE = 3; | ||
private static final int MINIMUM_ANSWER_LENGTH = 50; | ||
private static final int MAXIMUM_ANSWER_LENGTH = 500; | ||
|
||
private final RoleApplicationHandler handler; | ||
private final RoleApplicationSystemConfig config; | ||
|
||
private static final Logger logger = | ||
LoggerFactory.getLogger(CreateRoleApplicationCommand.class); | ||
|
||
/** | ||
* Constructs a new {@link CreateRoleApplicationCommand} with the specified configuration. | ||
* <p> | ||
* This command is designed to generate an application form for members to apply for roles. | ||
* | ||
* @param config the configuration containing the settings for the application form | ||
*/ | ||
public CreateRoleApplicationCommand(Config config) { | ||
super("application-form", "Generates an application form for members to apply for roles.", | ||
CommandVisibility.GUILD); | ||
|
||
this.config = config.getRoleApplicationSystemConfig(); | ||
|
||
generateRoleOptions(); | ||
handler = new RoleApplicationHandler(this.config); | ||
} | ||
|
||
/** | ||
* Populates this command's instance {@link SlashCommandData} object with the proper arguments | ||
* for this command. | ||
*/ | ||
private void generateRoleOptions() { | ||
final SlashCommandData data = getData(); | ||
|
||
IntStream.range(1, OPTIONAL_ROLES_AMOUNT + 1).forEach(index -> { | ||
data.addOption(OptionType.STRING, generateOptionId("title", index), | ||
"The title of the role"); | ||
data.addOption(OptionType.STRING, generateOptionId("description", index), | ||
"The description of the role"); | ||
data.addOption(OptionType.STRING, generateOptionId("emoji", index), | ||
"The emoji of the role"); | ||
}); | ||
} | ||
|
||
private static String generateOptionId(String name, int id) { | ||
return "%s%s%d".formatted(name, OPTION_PARAM_ID_DELIMITER, id); | ||
} | ||
|
||
@Override | ||
public void onSlashCommand(SlashCommandInteractionEvent event) { | ||
if (!handleHasPermissions(event)) { | ||
return; | ||
} | ||
|
||
final List<OptionMapping> optionMappings = event.getInteraction().getOptions(); | ||
if (optionMappings.isEmpty()) { | ||
event.reply("You have to select at least one role.").setEphemeral(true).queue(); | ||
return; | ||
} | ||
|
||
long incorrectArgsCount = getIncorrectRoleArgsCount(optionMappings); | ||
if (incorrectArgsCount > 0) { | ||
event.reply("Missing information for %d roles.".formatted(incorrectArgsCount)) | ||
.setEphemeral(true) | ||
.queue(); | ||
return; | ||
} | ||
|
||
sendMenu(event); | ||
} | ||
|
||
@Override | ||
public void onStringSelectSelection(StringSelectInteractionEvent event, List<String> args) { | ||
SelectOption selectOption = event.getSelectedOptions().getFirst(); | ||
Member member = event.getMember(); | ||
|
||
if (member == null) { | ||
logger.error("Member was null during onStringSelectSelection()"); | ||
return; | ||
} | ||
|
||
if (selectOption == null) { | ||
logger.error("selectOption was null during onStringSelectSelection()"); | ||
return; | ||
} | ||
|
||
long remainingMinutes = handler.getMemberCooldownMinutes(member); | ||
if (remainingMinutes > 0) { | ||
String correctMinutesWord = remainingMinutes == 1 ? "minute" : "minutes"; | ||
|
||
event | ||
.reply("Please wait %d %s before sending a new application form." | ||
.formatted(remainingMinutes, correctMinutesWord)) | ||
.setEphemeral(true) | ||
.queue(); | ||
return; | ||
} | ||
|
||
TextInput body = TextInput | ||
.create(generateComponentId(event.getUser().getId()), config.defaultQuestion(), | ||
TextInputStyle.PARAGRAPH) | ||
.setRequired(true) | ||
.setRequiredRange(MINIMUM_ANSWER_LENGTH, MAXIMUM_ANSWER_LENGTH) | ||
.setPlaceholder("Enter your answer here") | ||
.build(); | ||
|
||
EmojiUnion emoji = selectOption.getEmoji(); | ||
String roleDisplayName; | ||
|
||
if (emoji == null) { | ||
roleDisplayName = selectOption.getLabel(); | ||
} else { | ||
roleDisplayName = "%s %s".formatted(emoji.getFormatted(), selectOption.getLabel()); | ||
} | ||
|
||
Modal modal = Modal | ||
.create(generateComponentId(event.getUser().getId(), roleDisplayName), | ||
String.format("Application form - %s", selectOption.getLabel())) | ||
.addActionRow(ActionRow.of(body).getComponents()) | ||
.build(); | ||
|
||
event.replyModal(modal).queue(); | ||
} | ||
|
||
/** | ||
* Checks a given list of passed arguments (from a user) and calculates how many roles have | ||
* missing data. | ||
* | ||
* @param args the list of passed arguments | ||
* @return the amount of roles with missing data | ||
*/ | ||
private static long getIncorrectRoleArgsCount(final List<OptionMapping> args) { | ||
final Map<String, Integer> frequencyMap = new HashMap<>(); | ||
|
||
args.stream() | ||
.map(OptionMapping::getName) | ||
.map(name -> name.split(OPTION_PARAM_ID_DELIMITER)[1]) | ||
.forEach(number -> frequencyMap.merge(number, 1, Integer::sum)); | ||
|
||
return frequencyMap.values().stream().filter(value -> value != OPTIONS_PER_ROLE).count(); | ||
} | ||
|
||
/** | ||
* Populates a {@link StringSelectMenu.Builder} with application roles. | ||
* | ||
* @param menuBuilder the menu builder to populate | ||
* @param args the arguments which contain data about the roles | ||
*/ | ||
private void addRolesToMenu(StringSelectMenu.Builder menuBuilder, | ||
final List<OptionMapping> args) { | ||
final Map<Integer, MenuRole> roles = new HashMap<>(); | ||
|
||
for (int i = 0; i < args.size(); i += OPTIONS_PER_ROLE) { | ||
OptionMapping optionTitle = args.get(i); | ||
OptionMapping optionDescription = args.get(i + 1); | ||
OptionMapping optionEmoji = args.get(i + 2); | ||
|
||
roles.put(i, | ||
new MenuRole(optionTitle.getAsString(), | ||
generateComponentId(ROLE_COMPONENT_ID_HEADER, | ||
optionTitle.getAsString()), | ||
optionDescription.getAsString(), | ||
Emoji.fromFormatted(optionEmoji.getAsString()))); | ||
} | ||
|
||
roles.values() | ||
.forEach(role -> menuBuilder.addOption(role.title(), role.value(), role.description(), | ||
role.emoji())); | ||
} | ||
|
||
private boolean handleHasPermissions(SlashCommandInteractionEvent event) { | ||
Member member = event.getMember(); | ||
Guild guild = event.getGuild(); | ||
|
||
if (member == null || guild == null) { | ||
return false; | ||
} | ||
|
||
if (!member.hasPermission(Permission.MANAGE_ROLES)) { | ||
event.reply("You do not have the required manage role permission to use this command") | ||
.setEphemeral(true) | ||
.queue(); | ||
return false; | ||
} | ||
|
||
return true; | ||
} | ||
|
||
/** | ||
* Sends the initial embed and a button which displays role openings. | ||
* | ||
* @param event the command interaction event triggering the menu | ||
*/ | ||
private void sendMenu(final CommandInteraction event) { | ||
MessageEmbed embed = createApplicationEmbed(); | ||
|
||
StringSelectMenu.Builder menuBuilder = StringSelectMenu | ||
.create(generateComponentId(Lifespan.PERMANENT, event.getUser().getId())) | ||
.setPlaceholder("Select role to apply for") | ||
.setRequiredRange(1, 1); | ||
|
||
addRolesToMenu(menuBuilder, event.getOptions()); | ||
|
||
event.replyEmbeds(embed).addActionRow(menuBuilder.build()).queue(); | ||
} | ||
|
||
private static MessageEmbed createApplicationEmbed() { | ||
return new EmbedBuilder().setTitle("Apply for roles") | ||
.setDescription( | ||
""" | ||
We are always looking for community members that want to contribute to our community \ | ||
and take charge. If you are interested, you can apply for various positions here! 😎""") | ||
.setColor(AMBIENT_COLOR) | ||
.build(); | ||
} | ||
|
||
public RoleApplicationHandler getApplicationApplyHandler() { | ||
return handler; | ||
} | ||
|
||
@Override | ||
public void onModalSubmitted(ModalInteractionEvent event, List<String> args) { | ||
getApplicationApplyHandler().submitApplicationFromModalInteraction(event, args); | ||
} | ||
|
||
/** | ||
* Wrapper class which represents a menu role for the application create command. | ||
* <p> | ||
* The reason this exists is due to the fact that {@link StringSelectMenu.Builder} does not have | ||
* a method which takes emojis as input as of writing this, so we have to elegantly pass in | ||
* custom data from this POJO. | ||
*/ | ||
private record MenuRole(String title, String value, String description, @Nullable Emoji emoji) { | ||
|
||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.