Skip to content

Commit 5a841e6

Browse files
authored
#544: split bountycontrolpanel into separate files (#677)
* #544: split bountycontrolpanel into separate files * typing improvements - fix type documentation of logicLayer in bountycontrolpanel index - use POJO instead of Map for select option dictionary - the dictionary never gets iterated over and its data is static - add BuildErrors for missing select option name, duplicate select option name, and missing select option functionality * consolidate slash command earlyouts (#678)
1 parent 6a62dbf commit 5a841e6

34 files changed

Lines changed: 853 additions & 686 deletions

package-lock.json

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

source/frontend/classes/CommandWrapper.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ class SubcommandWrapper {
7878
/**
7979
* @param {string} nameInput
8080
* @param {string} descriptionInput
81-
* @param {(interaction: ChatInputCommandInteraction, origin: InteractionOrigin, runMode: string, logicLayer: typeof import("../../logic/index.js")) => Promise<void>} executeFunction
81+
* @param {(interaction: ChatInputCommandInteraction, origin: InteractionOrigin, runMode: "development" | "test" | "production", logicLayer: typeof import("../../logic/index.js")) => Promise<void>} executeFunction
8282
*/
8383
constructor(nameInput, descriptionInput, executeFunction) {
8484
this.data = {

source/frontend/classes/MessageComponentWrapper.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const { ButtonInteraction, AnySelectMenuInteraction } = require("discord.js");
22
const { InteractionWrapper, InteractionOrigin } = require("./InteractionWrapper.js");
3+
const { BuildError } = require("./BuildError.js");
34

45
class MessageComponentWrapper extends InteractionWrapper {
56
/** IHP parent wrapper for buttons and selects
@@ -36,4 +37,21 @@ class SelectWrapper extends MessageComponentWrapper {
3637
}
3738
};
3839

39-
module.exports = { MessageComponentWrapper, ButtonWrapper, SelectWrapper };
40+
class SelectOptionWrapper {
41+
/**
42+
* @param {string} nameInput
43+
* @param {(interaction: ChatInputCommandInteraction, origin: InteractionOrigin, runMode: "development" | "test" | "production", logicLayer: typeof import("../../logic/index.js"), args: unknown[]) => Promise<void>} executeFunction
44+
*/
45+
constructor(nameInput, executeFunction) {
46+
if (!nameInput) {
47+
throw new BuildError("missing select option name");
48+
}
49+
if (!executeFunction) {
50+
throw new BuildError(`missing execute function for select option: ${this.name}`);
51+
}
52+
this.name = nameInput;
53+
this.execute = executeFunction;
54+
}
55+
}
56+
57+
module.exports = { MessageComponentWrapper, ButtonWrapper, SelectWrapper, SelectOptionWrapper };
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
const { MessageFlags, ChatInputCommandInteraction, GuildMember } = require("discord.js");
2+
const { commandMention } = require("../shared");
3+
const { InteractionOrigin } = require("../classes");
4+
const { Bounty, Hunter } = require("../../database/models");
5+
6+
/** @param {(interaction: ChatInputCommandInteraction, origin: InteractionOrigin, runMode: "development" | "test" | "production", logicLayer: typeof import("../../logic"), entities: { member: GuildMember; hunter: Hunter; }) => Promise<void>} next */
7+
function ensureUserFromSlashOptionHasBountyHunter(optionName, next) {
8+
/**
9+
* @param {ChatInputCommandInteraction} interaction
10+
* @param {InteractionOrigin} origin
11+
* @param {"development" | "test" | "production"} runMode
12+
* @param {typeof import("../../logic")} logicLayer
13+
*/
14+
return async (interaction, origin, runMode, logicLayer) => {
15+
const member = interaction.options.getMember(optionName);
16+
const hunter = member.id === origin.hunter.userId ? origin.hunter : await logicLayer.hunters.findOneHunter(member.id, interaction.guild.id);
17+
if (!hunter) {
18+
interaction.reply({ content: `${member} has not interacted with BountyBot on this server.`, flags: MessageFlags.Ephemeral });
19+
return;
20+
}
21+
next(interaction, origin, runMode, logicLayer, { member, hunter });
22+
}
23+
}
24+
25+
/** @param {(interaction: ChatInputCommandInteraction, origin: InteractionOrigin, runMode: "development" | "test" | "production", logicLayer: typeof import("../../logic"), input: number) => Promise<void>} next */
26+
function ensureNumberFromSlashOptionIsGreaterThanOne(optionName, next) {
27+
/**
28+
* @param {ChatInputCommandInteraction} interaction
29+
* @param {InteractionOrigin} origin
30+
* @param {"development" | "test" | "production"} runMode
31+
* @param {typeof import("../../logic")} logicLayer
32+
*/
33+
return async (interaction, origin, runMode, logicLayer) => {
34+
const input = interaction.options.getNumber(optionName);
35+
if (input <= 1) {
36+
interaction.reply({ content: `The value provided for ${optionName} must be greater than 1.`, flags: MessageFlags.Ephemeral })
37+
return;
38+
}
39+
next(interaction, origin, runMode, logicLayer, input);
40+
}
41+
}
42+
43+
/** @param {(interaction: ChatInputCommandInteraction, origin: InteractionOrigin, runMode: "development" | "test" | "production", logicLayer: typeof import("../../logic"), bounties: Bounty[]) => Promise<void>} next */
44+
function ensureCompanyHasEnoughOpenEvergreenBounties(countThreshold, next) {
45+
/**
46+
* @param {ChatInputCommandInteraction} interaction
47+
* @param {InteractionOrigin} origin
48+
* @param {"development" | "test" | "production"} runMode
49+
* @param {typeof import("../../logic")} logicLayer
50+
*/
51+
return async (interaction, origin, runMode, logicLayer) => {
52+
const evergreenBounties = await logicLayer.bounties.findEvergreenBounties(interaction.guild.id);
53+
if (evergreenBounties.length < countThreshold) {
54+
let content = countThreshold === 1 ?
55+
"This server doesn't appear to have any evergreen bounties." :
56+
"This server must have at least 2 evergreen bounties to be able to swap rewards.";
57+
interaction.reply({ content, flags: MessageFlags.Ephemeral });
58+
return;
59+
}
60+
next(interaction, origin, runMode, logicLayer, evergreenBounties);
61+
}
62+
}
63+
64+
/** @param {(interaction: ChatInputCommandInteraction, origin: InteractionOrigin, runMode: "development" | "test" | "production", logicLayer: typeof import("../../logic"), bounties: Bounty[]) => Promise<void>} next */
65+
function ensureHunterHasOpenBounty(next) {
66+
/**
67+
* @param {ChatInputCommandInteraction} interaction
68+
* @param {InteractionOrigin} origin
69+
* @param {"development" | "test" | "production"} runMode
70+
* @param {typeof import("../../logic")} logicLayer
71+
*/
72+
return async (interaction, origin, runMode, logicLayer) => {
73+
const bounties = await logicLayer.bounties.findOpenBounties(origin.user.id, origin.company.id);
74+
if (bounties.length < 1) {
75+
interaction.reply({ content: `You don't appear to have any open bounties on this server. Post one with ${commandMention("bounty post")}?`, flags: MessageFlags.Ephemeral });
76+
return;
77+
}
78+
next(interaction, origin, runMode, logicLayer, bounties);
79+
}
80+
}
81+
82+
module.exports = {
83+
ensureUserFromSlashOptionHasBountyHunter,
84+
ensureNumberFromSlashOptionIsGreaterThanOne,
85+
ensureCompanyHasEnoughOpenEvergreenBounties,
86+
ensureHunterHasOpenBounty
87+
};

source/frontend/commands/bounty/complete.js

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,10 @@ const { commandMention, bountyEmbed, goalCompletionEmbed, sendRewardMessage, syn
44
const { SubcommandWrapper } = require("../../classes");
55
const { Company } = require("../../../database/models");
66
const { SKIP_INTERACTION_HANDLING } = require("../../../constants");
7+
const { ensureHunterHasOpenBounty } = require("../_earlyOuts");
78

89
module.exports = new SubcommandWrapper("complete", "Close one of your open bounties, distributing rewards to hunters who turned it in",
9-
async function executeSubcommand(interaction, origin, runMode, logicLayer) {
10-
const openBounties = await logicLayer.bounties.findOpenBounties(origin.user.id, origin.company.id);
11-
if (openBounties.length < 1) {
12-
interaction.reply({ content: "You don't appear to have any open bounties in this server.", flags: MessageFlags.Ephemeral });
13-
return;
14-
}
15-
10+
ensureHunterHasOpenBounty(async function executeSubcommand(interaction, origin, runMode, logicLayer, bounties) {
1611
const labelIdBountyId = "bounty-id";
1712
const labelIdBountyHunters = "hunters";
1813
const maxHunters = 10;
@@ -23,7 +18,7 @@ module.exports = new SubcommandWrapper("complete", "Close one of your open bount
2318
.setStringSelectMenuComponent(
2419
new StringSelectMenuBuilder().setCustomId(labelIdBountyId)
2520
.setPlaceholder("Select a bounty...")
26-
.setOptions(selectOptionsFromBounties(openBounties))
21+
.setOptions(selectOptionsFromBounties(bounties))
2722
),
2823
new LabelBuilder().setLabel("Extra Turn-Ins")
2924
.setUserSelectMenuComponent(
@@ -140,5 +135,5 @@ module.exports = new SubcommandWrapper("complete", "Close one of your open bount
140135
if (bounty.scheduledEventId) {
141136
modalSubmission.guild.scheduledEvents.delete(bounty.scheduledEventId).catch(butIgnoreErrorIf(isUnknownGuildScheduledEventError, isMissingPermissionError));
142137
}
143-
}
138+
})
144139
);

source/frontend/commands/bounty/edit.js

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,10 @@ const { ActionRowBuilder, StringSelectMenuBuilder, MessageFlags, ComponentType,
22
const { SubcommandWrapper } = require("../../classes");
33
const { textsHaveAutoModInfraction, commandMention, bountyEmbed, validateScheduledEventTimestamps, bountyScheduledEventPayload, editBountyModalAndSubmissionOptions, selectOptionsFromBounties, unarchiveAndUnlockThread, butIgnoreInteractionCollectorErrors, getBountyBoardThread, refreshBountyBoardThread } = require("../../shared");
44
const { SKIP_INTERACTION_HANDLING } = require("../../../constants");
5+
const { ensureHunterHasOpenBounty } = require("../_earlyOuts");
56

67
module.exports = new SubcommandWrapper("edit", "Edit the title, description, image, or time of one of your bounties",
7-
async function executeSubcommand(interaction, origin, runMode, logicLayer) {
8-
const openBounties = await logicLayer.bounties.findOpenBounties(interaction.user.id, interaction.guild.id);
9-
if (openBounties.length < 1) {
10-
interaction.reply({ content: "You don't seem to have any open bounties at the moment.", flags: MessageFlags.Ephemeral });
11-
return;
12-
}
13-
8+
ensureHunterHasOpenBounty(async function executeSubcommand(interaction, origin, runMode, logicLayer, openBounties) {
149
interaction.reply({
1510
content: "You can select one of your open bounties to edit below.\n\nKeep in mind that while you're in charge of adding completers and ending the bounty, the bounty is still subject to server rules and moderation.",
1611
components: [
@@ -109,5 +104,5 @@ module.exports = new SubcommandWrapper("edit", "Edit the title, description, ima
109104
}
110105
});
111106
}).catch(butIgnoreInteractionCollectorErrors);
112-
}
107+
})
113108
);

source/frontend/commands/bounty/ping.js

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,10 @@ const { SubcommandWrapper } = require("../../classes");
44
const { bountyPing } = require("../../shared/flows/bountyPing");
55
const { selectOptionsFromBounties, butIgnoreInteractionCollectorErrors, getBountyBoardThread } = require("../../shared");
66
const { timeConversion } = require("../../../shared");
7+
const { ensureHunterHasOpenBounty } = require("../_earlyOuts");
78

89
module.exports = new SubcommandWrapper("ping", "Mention bounty hunters that reacted to your bounty's thread or event",
9-
async function executeSubcommand(interaction, origin, runMode, logicLayer) {
10-
const openBounties = await logicLayer.bounties.findOpenBounties(origin.user.id, origin.company.id);
11-
if (openBounties.length < 1) {
12-
interaction.reply({ content: "You don't have any open bounties on this server.", flags: MessageFlags.Ephemeral });
13-
return;
14-
}
15-
10+
ensureHunterHasOpenBounty(async function executeSubcommand(interaction, origin, runMode, logicLayer, bounties) {
1611
const labelIdBountyId = "bounty-id";
1712
const labelIdMessage = "message";
1813
const labelIdExcludedBountyHunters = "bounty-hunters";
@@ -24,7 +19,7 @@ module.exports = new SubcommandWrapper("ping", "Mention bounty hunters that reac
2419
.setStringSelectMenuComponent(
2520
new StringSelectMenuBuilder().setCustomId(labelIdBountyId)
2621
.setPlaceholder("Select a bounty...")
27-
.setOptions(selectOptionsFromBounties(openBounties))
22+
.setOptions(selectOptionsFromBounties(bounties))
2823
),
2924
new LabelBuilder().setLabel("Message")
3025
.setTextInputComponent(
@@ -55,5 +50,5 @@ module.exports = new SubcommandWrapper("ping", "Mention bounty hunters that reac
5550

5651
const bountyThread = await getBountyBoardThread(modalSubmission.guild, origin.company.bountyBoardId, bounty.postingId);
5752
bountyPing(modalSubmission, { message: labelIdMessage, excludedBountyHunters: labelIdExcludedBountyHunters }, bounty, bountyThread);
58-
}
53+
})
5954
);

source/frontend/commands/bounty/record-turn-ins.js

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
11
const { userMention, bold, MessageFlags, StringSelectMenuBuilder, UserSelectMenuBuilder, ModalBuilder, LabelBuilder, PermissionFlagsBits } = require("discord.js");
22
const { SubcommandWrapper } = require("../../classes");
3-
const { sentenceListEN, commandMention, randomCongratulatoryPhrase, selectOptionsFromBounties, butIgnoreInteractionCollectorErrors, getBountyBoardThread, bountyEmbed, unarchiveAndUnlockThread } = require("../../shared");
3+
const { sentenceListEN, randomCongratulatoryPhrase, selectOptionsFromBounties, butIgnoreInteractionCollectorErrors, getBountyBoardThread, bountyEmbed, unarchiveAndUnlockThread } = require("../../shared");
44
const { timeConversion } = require("../../../shared");
55
const { SKIP_INTERACTION_HANDLING } = require("../../../constants");
6+
const { ensureHunterHasOpenBounty } = require("../_earlyOuts");
67

78
module.exports = new SubcommandWrapper("record-turn-ins", "Record turn-ins of one of your bounties for up to 5 bounty hunters",
8-
async function executeSubcommand(interaction, origin, runMode, logicLayer) {
9-
const openBounties = await logicLayer.bounties.findOpenBounties(interaction.user.id, interaction.guild.id);
10-
if (openBounties.length < 1) {
11-
interaction.reply({ content: `You don't currently have any open bounties. Post one with ${commandMention("bounty post")}?`, flags: MessageFlags.Ephemeral });
12-
return;
13-
}
14-
9+
ensureHunterHasOpenBounty(async function executeSubcommand(interaction, origin, runMode, logicLayer, bounties) {
1510
const labelIdBountyId = "bounty-id";
1611
const labelIdBountyHunters = "bounty-hunters";
1712
const maxHunters = 10;
@@ -22,7 +17,7 @@ module.exports = new SubcommandWrapper("record-turn-ins", "Record turn-ins of on
2217
.setStringSelectMenuComponent(
2318
new StringSelectMenuBuilder().setCustomId(labelIdBountyId)
2419
.setPlaceholder("Select a bounty...")
25-
.setOptions(selectOptionsFromBounties(openBounties))
20+
.setOptions(selectOptionsFromBounties(bounties))
2621
),
2722
new LabelBuilder().setLabel("Bounty Hunters")
2823
.setUserSelectMenuComponent(
@@ -69,5 +64,5 @@ module.exports = new SubcommandWrapper("record-turn-ins", "Record turn-ins of on
6964
}
7065

7166
modalSubmission.reply({ content: sentences.join("\n\n"), flags: MessageFlags.Ephemeral });
72-
}
67+
})
7368
);

source/frontend/commands/bounty/revoke-turn-ins.js

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
11
const { MessageFlags, userMention, bold, StringSelectMenuBuilder, UserSelectMenuBuilder, ModalBuilder, LabelBuilder, PermissionFlagsBits } = require("discord.js");
22
const { SubcommandWrapper } = require("../../classes");
3-
const { sentenceListEN, selectOptionsFromBounties, commandMention, butIgnoreInteractionCollectorErrors, getBountyBoardThread, bountyEmbed, unarchiveAndUnlockThread } = require("../../shared");
3+
const { sentenceListEN, selectOptionsFromBounties, butIgnoreInteractionCollectorErrors, getBountyBoardThread, bountyEmbed, unarchiveAndUnlockThread } = require("../../shared");
44
const { timeConversion } = require("../../../shared");
55
const { SKIP_INTERACTION_HANDLING } = require("../../../constants");
6+
const { ensureHunterHasOpenBounty } = require("../_earlyOuts");
67

78
module.exports = new SubcommandWrapper("revoke-turn-ins", "Revoke the turn-ins of up to 5 bounty hunters on one of your bounties",
8-
async function executeSubcommand(interaction, origin, runMode, logicLayer) {
9-
const openBounties = await logicLayer.bounties.findOpenBounties(interaction.user.id, interaction.guild.id);
10-
if (openBounties.length < 1) {
11-
interaction.reply({ content: `You don't currently have any open bounties. Post one with ${commandMention("bounty post")}?`, flags: MessageFlags.Ephemeral });
12-
return;
13-
}
14-
9+
ensureHunterHasOpenBounty(async function executeSubcommand(interaction, origin, runMode, logicLayer, bounties) {
1510
const labelIdBountyId = "bounty-id";
1611
const labelIdBountyHunters = "bounty-hunters";
1712
const maxHunters = 10;
@@ -22,7 +17,7 @@ module.exports = new SubcommandWrapper("revoke-turn-ins", "Revoke the turn-ins o
2217
.setStringSelectMenuComponent(
2318
new StringSelectMenuBuilder().setCustomId(labelIdBountyId)
2419
.setPlaceholder("Select a bounty...")
25-
.setOptions(selectOptionsFromBounties(openBounties))
20+
.setOptions(selectOptionsFromBounties(bounties))
2621
),
2722
new LabelBuilder().setLabel("Bounty Hunters")
2823
.setUserSelectMenuComponent(
@@ -59,5 +54,5 @@ module.exports = new SubcommandWrapper("revoke-turn-ins", "Revoke the turn-ins o
5954
bountyThread.send({ content: `${mentionList} ${removedIds.length === 1 ? "has had their turn-in" : "have had their turn-ins"} revoked.` });
6055
}
6156
}
62-
}
57+
})
6358
);

source/frontend/commands/bounty/showcase.js

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,16 @@ const { SubcommandWrapper } = require("../../classes");
33
const { timeConversion, discordTimestamp } = require("../../../shared");
44
const { SKIP_INTERACTION_HANDLING } = require("../../../constants");
55
const { selectOptionsFromBounties, bountyEmbed, unarchiveAndUnlockThread, butIgnoreInteractionCollectorErrors, getBountyBoardThread } = require("../../shared");
6+
const { ensureHunterHasOpenBounty } = require("../_earlyOuts");
67

78
module.exports = new SubcommandWrapper("showcase", "Show the embed for one of your existing bounties and increase the reward",
8-
async function executeSubcommand(interaction, origin, runMode, logicLayer) {
9+
ensureHunterHasOpenBounty(async function executeSubcommand(interaction, origin, runMode, logicLayer, bounties) {
910
const nextShowcaseInMS = new Date(origin.hunter.lastShowcaseTimestamp).valueOf() + timeConversion(1, "w", "ms");
1011
if (runMode === "production" && Date.now() < nextShowcaseInMS) {
1112
interaction.reply({ content: `You can showcase another bounty in ${discordTimestamp(Math.floor(nextShowcaseInMS / 1000), TimestampStyles.RelativeTime)}.`, flags: MessageFlags.Ephemeral });
1213
return;
1314
}
1415

15-
const existingBounties = await logicLayer.bounties.findOpenBounties(interaction.user.id, interaction.guildId);
16-
if (existingBounties.length < 1) {
17-
interaction.reply({ content: "You doesn't have any open bounties posted.", flags: MessageFlags.Ephemeral });
18-
return;
19-
}
20-
2116
if (!interaction.channel.members.has(interaction.client.user.id)) {
2217
interaction.reply({ content: "BountyBot can't post public messages in this channel.", flags: MessageFlags.Ephemeral });
2318
return;
@@ -34,7 +29,7 @@ module.exports = new SubcommandWrapper("showcase", "Show the embed for one of yo
3429
.setStringSelectMenuComponent(
3530
new StringSelectMenuBuilder().setCustomId(labelIdBountyId)
3631
.setPlaceholder("Select a bounty...")
37-
.setOptions(selectOptionsFromBounties(existingBounties))
32+
.setOptions(selectOptionsFromBounties(bounties))
3833
)
3934
);
4035
await interaction.showModal(modal);
@@ -47,7 +42,7 @@ module.exports = new SubcommandWrapper("showcase", "Show the embed for one of yo
4742
/** Unnecessary Validations
4843
* "user can view and send messages in target channel"
4944
* - User could not have sent slash command if unable to view and send messages. In case of input persisting over permission change, a showcase is low enough stakes to allow.
50-
*/
45+
*/
5146
const bountyId = modalSubmission.fields.getStringSelectValues(labelIdBountyId)[0];
5247
let bounty = await logicLayer.bounties.findBounty(bountyId);
5348
if (bounty.state !== "open") {
@@ -72,5 +67,5 @@ module.exports = new SubcommandWrapper("showcase", "Show the embed for one of yo
7267
bountyThread.send({ content: `${modalSubmission.member} increased the reward on this bounty!`, flags: MessageFlags.SuppressNotifications });
7368
}
7469
}
75-
}
70+
})
7671
);

0 commit comments

Comments
 (0)