Skip to content

Commit d381399

Browse files
authored
feat: create role application system (#1049)
Signed-off-by: Chris Sdogkos <work@chris-sdogkos.com>
1 parent 90799e8 commit d381399

7 files changed

Lines changed: 517 additions & 0 deletions

File tree

application/config.json.template

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,10 @@
199199
"channel": "quotes",
200200
"reactionEmoji": "⭐"
201201
},
202+
"roleApplicationSystem": {
203+
"submissionsChannelPattern": "staff-applications",
204+
"defaultQuestion": "What makes you a good addition to the team?"
205+
},
202206
"memberCountCategoryPattern": "Info",
203207
"topHelpers": {
204208
"rolePattern": "Top Helper.*",

application/src/main/java/org/togetherjava/tjbot/config/Config.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ public final class Config {
4848
private final RSSFeedsConfig rssFeedsConfig;
4949
private final String selectRolesChannelPattern;
5050
private final String memberCountCategoryPattern;
51+
private final RoleApplicationSystemConfig roleApplicationSystemConfig;
5152
private final QuoteBoardConfig quoteBoardConfig;
5253
private final TopHelpersConfig topHelpers;
5354
private final DynamicVoiceChatConfig dynamicVoiceChatConfig;
@@ -106,6 +107,8 @@ private Config(@JsonProperty(value = "token", required = true) String token,
106107
required = true) String selectRolesChannelPattern,
107108
@JsonProperty(value = "quoteBoardConfig",
108109
required = true) QuoteBoardConfig quoteBoardConfig,
110+
@JsonProperty(value = "roleApplicationSystem",
111+
required = true) RoleApplicationSystemConfig roleApplicationSystemConfig,
109112
@JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers,
110113
@JsonProperty(value = "dynamicVoiceChatConfig",
111114
required = true) DynamicVoiceChatConfig dynamicVoiceChatConfig) {
@@ -144,6 +147,7 @@ private Config(@JsonProperty(value = "token", required = true) String token,
144147
this.rssFeedsConfig = Objects.requireNonNull(rssFeedsConfig);
145148
this.selectRolesChannelPattern = Objects.requireNonNull(selectRolesChannelPattern);
146149
this.quoteBoardConfig = Objects.requireNonNull(quoteBoardConfig);
150+
this.roleApplicationSystemConfig = roleApplicationSystemConfig;
147151
this.topHelpers = Objects.requireNonNull(topHelpers);
148152
this.dynamicVoiceChatConfig = Objects.requireNonNull(dynamicVoiceChatConfig);
149153
}
@@ -460,6 +464,15 @@ public String getMemberCountCategoryPattern() {
460464
return memberCountCategoryPattern;
461465
}
462466

467+
/**
468+
* The configuration related to the application form.
469+
*
470+
* @return the application form config
471+
*/
472+
public RoleApplicationSystemConfig getRoleApplicationSystemConfig() {
473+
return roleApplicationSystemConfig;
474+
}
475+
463476
/**
464477
* Gets the RSS feeds configuration.
465478
*
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package org.togetherjava.tjbot.config;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import net.dv8tion.jda.api.interactions.components.text.TextInput;
5+
6+
import java.util.Objects;
7+
8+
/**
9+
* Represents the configuration for an application form, including roles and application channel
10+
* pattern.
11+
*
12+
* @param submissionsChannelPattern the pattern used to identify the submissions channel where
13+
* applications are sent
14+
* @param defaultQuestion the default question that will be asked in the role application form
15+
*/
16+
public record RoleApplicationSystemConfig(
17+
@JsonProperty(value = "submissionsChannelPattern",
18+
required = true) String submissionsChannelPattern,
19+
@JsonProperty(value = "defaultQuestion", required = true) String defaultQuestion) {
20+
21+
/**
22+
* Constructs an instance of {@link RoleApplicationSystemConfig} with the provided parameters.
23+
* <p>
24+
* This constructor ensures that {@code submissionsChannelPattern} and {@code defaultQuestion}
25+
* are not null and that the length of the {@code defaultQuestion} does not exceed the maximum
26+
* allowed length.
27+
*/
28+
public RoleApplicationSystemConfig {
29+
Objects.requireNonNull(submissionsChannelPattern);
30+
Objects.requireNonNull(defaultQuestion);
31+
32+
if (defaultQuestion.length() > TextInput.MAX_LABEL_LENGTH) {
33+
throw new IllegalArgumentException(
34+
"defaultQuestion length is too long! Cannot be greater than %d"
35+
.formatted(TextInput.MAX_LABEL_LENGTH));
36+
}
37+
}
38+
}

application/src/main/java/org/togetherjava/tjbot/features/Features.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
import org.togetherjava.tjbot.features.projects.ProjectsThreadCreatedListener;
7070
import org.togetherjava.tjbot.features.reminder.RemindRoutine;
7171
import org.togetherjava.tjbot.features.reminder.ReminderCommand;
72+
import org.togetherjava.tjbot.features.roleapplication.CreateRoleApplicationCommand;
7273
import org.togetherjava.tjbot.features.rss.RSSHandlerRoutine;
7374
import org.togetherjava.tjbot.features.system.BotCore;
7475
import org.togetherjava.tjbot.features.system.LogLevelCommand;
@@ -219,6 +220,7 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
219220
features.add(new JShellCommand(jshellEval));
220221
features.add(new MessageCommand());
221222
features.add(new RewriteCommand(chatGptService));
223+
features.add(new CreateRoleApplicationCommand(config));
222224

223225
FeatureBlacklist<Class<?>> blacklist = blacklistConfig.normal();
224226
return blacklist.filterStream(features.stream(), Object::getClass).toList();
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
package org.togetherjava.tjbot.features.roleapplication;
2+
3+
import net.dv8tion.jda.api.EmbedBuilder;
4+
import net.dv8tion.jda.api.Permission;
5+
import net.dv8tion.jda.api.entities.Guild;
6+
import net.dv8tion.jda.api.entities.Member;
7+
import net.dv8tion.jda.api.entities.MessageEmbed;
8+
import net.dv8tion.jda.api.entities.emoji.Emoji;
9+
import net.dv8tion.jda.api.entities.emoji.EmojiUnion;
10+
import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent;
11+
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
12+
import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent;
13+
import net.dv8tion.jda.api.interactions.commands.CommandInteraction;
14+
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
15+
import net.dv8tion.jda.api.interactions.commands.OptionType;
16+
import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData;
17+
import net.dv8tion.jda.api.interactions.components.ActionRow;
18+
import net.dv8tion.jda.api.interactions.components.selections.SelectOption;
19+
import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu;
20+
import net.dv8tion.jda.api.interactions.components.text.TextInput;
21+
import net.dv8tion.jda.api.interactions.components.text.TextInputStyle;
22+
import net.dv8tion.jda.api.interactions.modals.Modal;
23+
import org.slf4j.Logger;
24+
import org.slf4j.LoggerFactory;
25+
26+
import org.togetherjava.tjbot.config.Config;
27+
import org.togetherjava.tjbot.config.RoleApplicationSystemConfig;
28+
import org.togetherjava.tjbot.features.CommandVisibility;
29+
import org.togetherjava.tjbot.features.SlashCommandAdapter;
30+
import org.togetherjava.tjbot.features.componentids.Lifespan;
31+
32+
import javax.annotation.Nullable;
33+
34+
import java.awt.Color;
35+
import java.util.HashMap;
36+
import java.util.List;
37+
import java.util.Map;
38+
import java.util.stream.IntStream;
39+
40+
/**
41+
* Represents a command to create an application form for members to apply for roles.
42+
* <p>
43+
* This command is designed to generate an application form for members to apply for roles within a
44+
* guild.
45+
*/
46+
public class CreateRoleApplicationCommand extends SlashCommandAdapter {
47+
protected static final Color AMBIENT_COLOR = new Color(24, 221, 136, 255);
48+
private static final int OPTIONAL_ROLES_AMOUNT = 5;
49+
private static final String ROLE_COMPONENT_ID_HEADER = "application-create";
50+
private static final String OPTION_PARAM_ID_DELIMITER = "_";
51+
private static final int OPTIONS_PER_ROLE = 3;
52+
private static final int MINIMUM_ANSWER_LENGTH = 50;
53+
private static final int MAXIMUM_ANSWER_LENGTH = 500;
54+
55+
private final RoleApplicationHandler handler;
56+
private final RoleApplicationSystemConfig config;
57+
58+
private static final Logger logger =
59+
LoggerFactory.getLogger(CreateRoleApplicationCommand.class);
60+
61+
/**
62+
* Constructs a new {@link CreateRoleApplicationCommand} with the specified configuration.
63+
* <p>
64+
* This command is designed to generate an application form for members to apply for roles.
65+
*
66+
* @param config the configuration containing the settings for the application form
67+
*/
68+
public CreateRoleApplicationCommand(Config config) {
69+
super("application-form", "Generates an application form for members to apply for roles.",
70+
CommandVisibility.GUILD);
71+
72+
this.config = config.getRoleApplicationSystemConfig();
73+
74+
generateRoleOptions();
75+
handler = new RoleApplicationHandler(this.config);
76+
}
77+
78+
/**
79+
* Populates this command's instance {@link SlashCommandData} object with the proper arguments
80+
* for this command.
81+
*/
82+
private void generateRoleOptions() {
83+
final SlashCommandData data = getData();
84+
85+
IntStream.range(1, OPTIONAL_ROLES_AMOUNT + 1).forEach(index -> {
86+
data.addOption(OptionType.STRING, generateOptionId("title", index),
87+
"The title of the role");
88+
data.addOption(OptionType.STRING, generateOptionId("description", index),
89+
"The description of the role");
90+
data.addOption(OptionType.STRING, generateOptionId("emoji", index),
91+
"The emoji of the role");
92+
});
93+
}
94+
95+
private static String generateOptionId(String name, int id) {
96+
return "%s%s%d".formatted(name, OPTION_PARAM_ID_DELIMITER, id);
97+
}
98+
99+
@Override
100+
public void onSlashCommand(SlashCommandInteractionEvent event) {
101+
if (!handleHasPermissions(event)) {
102+
return;
103+
}
104+
105+
final List<OptionMapping> optionMappings = event.getInteraction().getOptions();
106+
if (optionMappings.isEmpty()) {
107+
event.reply("You have to select at least one role.").setEphemeral(true).queue();
108+
return;
109+
}
110+
111+
long incorrectArgsCount = getIncorrectRoleArgsCount(optionMappings);
112+
if (incorrectArgsCount > 0) {
113+
event.reply("Missing information for %d roles.".formatted(incorrectArgsCount))
114+
.setEphemeral(true)
115+
.queue();
116+
return;
117+
}
118+
119+
sendMenu(event);
120+
}
121+
122+
@Override
123+
public void onStringSelectSelection(StringSelectInteractionEvent event, List<String> args) {
124+
SelectOption selectOption = event.getSelectedOptions().getFirst();
125+
Member member = event.getMember();
126+
127+
if (member == null) {
128+
logger.error("Member was null during onStringSelectSelection()");
129+
return;
130+
}
131+
132+
if (selectOption == null) {
133+
logger.error("selectOption was null during onStringSelectSelection()");
134+
return;
135+
}
136+
137+
long remainingMinutes = handler.getMemberCooldownMinutes(member);
138+
if (remainingMinutes > 0) {
139+
String correctMinutesWord = remainingMinutes == 1 ? "minute" : "minutes";
140+
141+
event
142+
.reply("Please wait %d %s before sending a new application form."
143+
.formatted(remainingMinutes, correctMinutesWord))
144+
.setEphemeral(true)
145+
.queue();
146+
return;
147+
}
148+
149+
TextInput body = TextInput
150+
.create(generateComponentId(event.getUser().getId()), config.defaultQuestion(),
151+
TextInputStyle.PARAGRAPH)
152+
.setRequired(true)
153+
.setRequiredRange(MINIMUM_ANSWER_LENGTH, MAXIMUM_ANSWER_LENGTH)
154+
.setPlaceholder("Enter your answer here")
155+
.build();
156+
157+
EmojiUnion emoji = selectOption.getEmoji();
158+
String roleDisplayName;
159+
160+
if (emoji == null) {
161+
roleDisplayName = selectOption.getLabel();
162+
} else {
163+
roleDisplayName = "%s %s".formatted(emoji.getFormatted(), selectOption.getLabel());
164+
}
165+
166+
Modal modal = Modal
167+
.create(generateComponentId(event.getUser().getId(), roleDisplayName),
168+
String.format("Application form - %s", selectOption.getLabel()))
169+
.addActionRow(ActionRow.of(body).getComponents())
170+
.build();
171+
172+
event.replyModal(modal).queue();
173+
}
174+
175+
/**
176+
* Checks a given list of passed arguments (from a user) and calculates how many roles have
177+
* missing data.
178+
*
179+
* @param args the list of passed arguments
180+
* @return the amount of roles with missing data
181+
*/
182+
private static long getIncorrectRoleArgsCount(final List<OptionMapping> args) {
183+
final Map<String, Integer> frequencyMap = new HashMap<>();
184+
185+
args.stream()
186+
.map(OptionMapping::getName)
187+
.map(name -> name.split(OPTION_PARAM_ID_DELIMITER)[1])
188+
.forEach(number -> frequencyMap.merge(number, 1, Integer::sum));
189+
190+
return frequencyMap.values().stream().filter(value -> value != OPTIONS_PER_ROLE).count();
191+
}
192+
193+
/**
194+
* Populates a {@link StringSelectMenu.Builder} with application roles.
195+
*
196+
* @param menuBuilder the menu builder to populate
197+
* @param args the arguments which contain data about the roles
198+
*/
199+
private void addRolesToMenu(StringSelectMenu.Builder menuBuilder,
200+
final List<OptionMapping> args) {
201+
final Map<Integer, MenuRole> roles = new HashMap<>();
202+
203+
for (int i = 0; i < args.size(); i += OPTIONS_PER_ROLE) {
204+
OptionMapping optionTitle = args.get(i);
205+
OptionMapping optionDescription = args.get(i + 1);
206+
OptionMapping optionEmoji = args.get(i + 2);
207+
208+
roles.put(i,
209+
new MenuRole(optionTitle.getAsString(),
210+
generateComponentId(ROLE_COMPONENT_ID_HEADER,
211+
optionTitle.getAsString()),
212+
optionDescription.getAsString(),
213+
Emoji.fromFormatted(optionEmoji.getAsString())));
214+
}
215+
216+
roles.values()
217+
.forEach(role -> menuBuilder.addOption(role.title(), role.value(), role.description(),
218+
role.emoji()));
219+
}
220+
221+
private boolean handleHasPermissions(SlashCommandInteractionEvent event) {
222+
Member member = event.getMember();
223+
Guild guild = event.getGuild();
224+
225+
if (member == null || guild == null) {
226+
return false;
227+
}
228+
229+
if (!member.hasPermission(Permission.MANAGE_ROLES)) {
230+
event.reply("You do not have the required manage role permission to use this command")
231+
.setEphemeral(true)
232+
.queue();
233+
return false;
234+
}
235+
236+
return true;
237+
}
238+
239+
/**
240+
* Sends the initial embed and a button which displays role openings.
241+
*
242+
* @param event the command interaction event triggering the menu
243+
*/
244+
private void sendMenu(final CommandInteraction event) {
245+
MessageEmbed embed = createApplicationEmbed();
246+
247+
StringSelectMenu.Builder menuBuilder = StringSelectMenu
248+
.create(generateComponentId(Lifespan.PERMANENT, event.getUser().getId()))
249+
.setPlaceholder("Select role to apply for")
250+
.setRequiredRange(1, 1);
251+
252+
addRolesToMenu(menuBuilder, event.getOptions());
253+
254+
event.replyEmbeds(embed).addActionRow(menuBuilder.build()).queue();
255+
}
256+
257+
private static MessageEmbed createApplicationEmbed() {
258+
return new EmbedBuilder().setTitle("Apply for roles")
259+
.setDescription(
260+
"""
261+
We are always looking for community members that want to contribute to our community \
262+
and take charge. If you are interested, you can apply for various positions here! 😎""")
263+
.setColor(AMBIENT_COLOR)
264+
.build();
265+
}
266+
267+
public RoleApplicationHandler getApplicationApplyHandler() {
268+
return handler;
269+
}
270+
271+
@Override
272+
public void onModalSubmitted(ModalInteractionEvent event, List<String> args) {
273+
getApplicationApplyHandler().submitApplicationFromModalInteraction(event, args);
274+
}
275+
276+
/**
277+
* Wrapper class which represents a menu role for the application create command.
278+
* <p>
279+
* The reason this exists is due to the fact that {@link StringSelectMenu.Builder} does not have
280+
* a method which takes emojis as input as of writing this, so we have to elegantly pass in
281+
* custom data from this POJO.
282+
*/
283+
private record MenuRole(String title, String value, String description, @Nullable Emoji emoji) {
284+
285+
}
286+
}

0 commit comments

Comments
 (0)