Skip to content

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
wants to merge 23 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
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 Oct 17, 2024
a42a428
feat: create role application command
christolis Oct 17, 2024
dcb241c
fix(handle-permissions): remove unnecessary check
christolis Oct 17, 2024
375ae45
refactor: make cooldown minutes check into separate method
christolis Oct 17, 2024
8651807
feat: move substring of label into config record
christolis Oct 17, 2024
0a04d90
docs: rewrite documentation for `RoleApplicationSystemConfig`
christolis Oct 17, 2024
8ab2f40
style: run spotlessApply gradle task
christolis Oct 17, 2024
f46c983
feat(cooldown): use the proper word depending on count
christolis Oct 19, 2024
3fb788a
application-create: hardcode certain config values
christolis Jul 1, 2025
c51c9ff
refactor: rename to CreateRoleApplicationCommand
christolis Jul 1, 2025
ee96742
refactor: rename to RoleApplicationHandler
christolis Jul 1, 2025
b6db802
refactor: simplify variable naming
christolis Jul 1, 2025
51e3149
generateRoleOptions: remove unnecessary parameter
christolis Jul 1, 2025
81b6557
refactor: make consants more verbose and precise
christolis Jul 1, 2025
83df552
refactor: use OPTIONS_PER_ROLE where needed
christolis Jul 1, 2025
4690d67
refactor: soften up application message
christolis Jul 1, 2025
98cdec5
refactor: remove unneeded word plural helper method
christolis Jul 1, 2025
77adc44
refactor: move `correctMinutesWord` inside block
christolis Jul 1, 2025
643d600
refactor: move `setTimestamp()` after `setFooter()`
christolis Jul 1, 2025
1507cc1
refactor: handle `roleString` from early on
christolis Jul 1, 2025
c71ec52
refactor(application-form): better exception handling
christolis Jul 1, 2025
c4aeb1a
refactor(application-form): reduce visibility
christolis Jul 1, 2025
4d2673d
log(application-command): handle null cases
christolis Jul 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions application/config.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -175,5 +175,9 @@
"fallbackChannelPattern": "java-news-and-changes",
"pollIntervalInMinutes": 10
},
"roleApplicationSystem": {
"submissionsChannelPattern": "staff-applications",
"defaultQuestion": "What makes you a good addition to the team?"
},
"memberCountCategoryPattern": "Info"
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,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 @@ -100,7 +101,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 @@ -135,6 +138,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 @@ -437,6 +441,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,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));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
import org.togetherjava.tjbot.features.projects.ProjectsThreadCreatedListener;
import org.togetherjava.tjbot.features.reminder.RemindRoutine;
import org.togetherjava.tjbot.features.reminder.ReminderCommand;
import org.togetherjava.tjbot.features.roleapplication.CreateRoleApplicationCommand;
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 @@ -197,6 +198,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 CreateRoleApplicationCommand(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,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) {

}
}
Loading