blob: 612d069537e0cdedf78e765abaa1ad0f2deb68ea [file] [log] [blame]
TheCodedProf4f79da12023-01-31 16:50:37 -05001import { ActionRowBuilder, APIMessageComponentEmoji, ButtonBuilder, ButtonInteraction, ButtonStyle, Collection, CommandInteraction, GuildMember, Message, ModalBuilder, ModalSubmitInteraction, PermissionsBitField, Role, RoleSelectMenuBuilder, RoleSelectMenuInteraction, SlashCommandSubcommandBuilder, StringSelectMenuBuilder, StringSelectMenuInteraction, StringSelectMenuOptionBuilder, TextInputBuilder, TextInputStyle } from "discord.js";
TheCodedProfa112f612023-01-28 18:06:45 -05002import client from "../../utils/client.js";
TheCodedProfb5e9d552023-01-29 15:43:26 -05003import createPageIndicator, { createVerticalTrack } from "../../utils/createPageIndicator.js";
4import { LoadingEmbed } from "../../utils/defaults.js";
5import EmojiEmbed from "../../utils/generateEmojiEmbed.js";
6import getEmojiByName from "../../utils/getEmojiByName.js";
7import ellipsis from "../../utils/ellipsis.js";
8import { modalInteractionCollector } from "../../utils/dualCollector.js";
TheCodedProfa112f612023-01-28 18:06:45 -05009
TheCodedProf4f79da12023-01-31 16:50:37 -050010const { renderRole } = client.logger
11
TheCodedProfa112f612023-01-28 18:06:45 -050012const command = (builder: SlashCommandSubcommandBuilder) =>
13 builder
14 .setName("tracks")
15 .setDescription("Manage the tracks for the server")
16
TheCodedProfb5e9d552023-01-29 15:43:26 -050017interface ObjectSchema {
18 name: string;
19 retainPrevious: boolean;
20 nullable: boolean;
21 track: string[];
22 manageableBy: string[];
23}
24
TheCodedProf4f79da12023-01-31 16:50:37 -050025
TheCodedProfb5e9d552023-01-29 15:43:26 -050026const editName = async (i: ButtonInteraction, interaction: StringSelectMenuInteraction | ButtonInteraction, m: Message, current?: string) => {
27
28 let name = current ?? "";
29 const modal = new ModalBuilder()
30 .setTitle("Edit Name and Description")
31 .setCustomId("editNameDescription")
32 .addComponents(
33 new ActionRowBuilder<TextInputBuilder>()
34 .addComponents(
35 new TextInputBuilder()
36 .setLabel("Name")
37 .setCustomId("name")
TheCodedProf1f675042023-02-16 17:01:29 -050038 .setPlaceholder("The name of the track (e.g. Moderators)")
TheCodedProfb5e9d552023-01-29 15:43:26 -050039 .setStyle(TextInputStyle.Short)
PineaFanb0d0c242023-02-05 10:59:45 +000040 .setValue(name)
TheCodedProfb5e9d552023-01-29 15:43:26 -050041 .setRequired(true)
42 )
43 )
44 const button = new ActionRowBuilder<ButtonBuilder>()
45 .addComponents(
46 new ButtonBuilder()
47 .setCustomId("back")
48 .setLabel("Back")
49 .setStyle(ButtonStyle.Secondary)
50 .setEmoji(getEmojiByName("CONTROL.LEFT", "id") as APIMessageComponentEmoji)
51 )
52
53 await i.showModal(modal)
54 await interaction.editReply({
55 embeds: [
56 new EmojiEmbed()
57 .setTitle("Tracks")
58 .setDescription("Modal opened. If you can't see it, click back and try again.")
59 .setStatus("Success")
60 ],
61 components: [button]
62 });
63
64 let out: ModalSubmitInteraction | null;
65 try {
TheCodedProf01cba762023-02-18 15:55:05 -050066 out = await modalInteractionCollector(m, interaction.user) as ModalSubmitInteraction | null;
TheCodedProfb5e9d552023-01-29 15:43:26 -050067 } catch (e) {
68 console.error(e);
69 out = null;
70 }
71 if(!out) return name;
72 if (out.isButton()) return name;
TheCodedProfb5e9d552023-01-29 15:43:26 -050073 name = out.fields.fields.find((f) => f.customId === "name")?.value ?? name;
74 return name
75
76}
77
78const reorderTracks = async (interaction: ButtonInteraction, m: Message, roles: Collection<string, Role>, currentObj: string[]) => {
PineaFanb0d0c242023-02-05 10:59:45 +000079 const reorderRow = new ActionRowBuilder<StringSelectMenuBuilder>()
TheCodedProfb5e9d552023-01-29 15:43:26 -050080 .addComponents(
81 new StringSelectMenuBuilder()
82 .setCustomId("reorder")
83 .setPlaceholder("Select all roles in the order you want users to gain them (Lowest to highest rank).")
84 .setMinValues(currentObj.length)
85 .setMaxValues(currentObj.length)
86 .addOptions(
87 currentObj.map((o, i) => new StringSelectMenuOptionBuilder()
88 .setLabel(roles.get(o)!.name)
89 .setValue(i.toString())
90 )
91 )
92 );
PineaFanb0d0c242023-02-05 10:59:45 +000093 const buttonRow = new ActionRowBuilder<ButtonBuilder>()
TheCodedProfb5e9d552023-01-29 15:43:26 -050094 .addComponents(
95 new ButtonBuilder()
96 .setCustomId("back")
97 .setLabel("Back")
98 .setStyle(ButtonStyle.Secondary)
99 .setEmoji(getEmojiByName("CONTROL.LEFT", "id") as APIMessageComponentEmoji)
100 )
101 await interaction.editReply({
102 embeds: [
103 new EmojiEmbed()
104 .setTitle("Tracks")
105 .setDescription("Select all roles in the order you want users to gain them (Lowest to highest rank).")
106 .setStatus("Success")
107 ],
108 components: [reorderRow, buttonRow]
109 });
110 let out: StringSelectMenuInteraction | ButtonInteraction | null;
111 try {
112 out = await m.awaitMessageComponent({
113 filter: (i) => i.channel!.id === interaction.channel!.id,
114 time: 300000
115 }) as StringSelectMenuInteraction | ButtonInteraction | null;
116 } catch (e) {
117 console.error(e);
118 out = null;
119 }
120 if(!out) return;
121 out.deferUpdate();
122 if (out.isButton()) return;
TheCodedProfb5e9d552023-01-29 15:43:26 -0500123 const values = out.values;
124
125 const newOrder: string[] = currentObj.map((_, i) => {
126 const index = values.findIndex(v => v === i.toString());
127 return currentObj[index];
128 }) as string[];
129
130 return newOrder;
131}
132
133const editTrack = async (interaction: ButtonInteraction | StringSelectMenuInteraction, message: Message, roles: Collection<string, Role>, current?: ObjectSchema) => {
TheCodedProf4f79da12023-01-31 16:50:37 -0500134 const isAdmin = (interaction.member!.permissions as PermissionsBitField).has("Administrator");
TheCodedProfb5e9d552023-01-29 15:43:26 -0500135 if(!current) {
136 current = {
137 name: "",
138 retainPrevious: false,
139 nullable: false,
140 track: [],
141 manageableBy: []
142 }
143 }
TheCodedProf4f79da12023-01-31 16:50:37 -0500144
TheCodedProfb5e9d552023-01-29 15:43:26 -0500145 const roleSelect = new ActionRowBuilder<RoleSelectMenuBuilder>()
146 .addComponents(
147 new RoleSelectMenuBuilder()
148 .setCustomId("addRole")
149 .setPlaceholder("Select a role to add")
TheCodedProf4f79da12023-01-31 16:50:37 -0500150 .setDisabled(!isAdmin)
TheCodedProfb5e9d552023-01-29 15:43:26 -0500151 );
152 let closed = false;
153 do {
154 const editableRoles: string[] = current.track.map((r) => {
TheCodedProf4f79da12023-01-31 16:50:37 -0500155 if(!(roles.get(r)!.position >= (interaction.member as GuildMember).roles.highest.position)) return roles.get(r)!.name;
TheCodedProfb5e9d552023-01-29 15:43:26 -0500156 }).filter(v => v !== undefined) as string[];
157 const selectMenu = new ActionRowBuilder<StringSelectMenuBuilder>()
158 .addComponents(
159 new StringSelectMenuBuilder()
160 .setCustomId("removeRole")
161 .setPlaceholder("Select a role to remove")
TheCodedProf4f79da12023-01-31 16:50:37 -0500162 .setDisabled(!isAdmin)
TheCodedProfb5e9d552023-01-29 15:43:26 -0500163 .addOptions(
164 editableRoles.map((r, i) => {
165 return new StringSelectMenuOptionBuilder()
166 .setLabel(r)
167 .setValue(i.toString())}
168 )
169 )
170 );
TheCodedProf4f79da12023-01-31 16:50:37 -0500171 const buttons = new ActionRowBuilder<ButtonBuilder>()
172 .addComponents(
173 new ButtonBuilder()
174 .setCustomId("back")
175 .setLabel("Back")
176 .setStyle(ButtonStyle.Secondary)
177 .setEmoji(getEmojiByName("CONTROL.LEFT", "id") as APIMessageComponentEmoji),
178 new ButtonBuilder()
179 .setCustomId("edit")
180 .setLabel("Edit Name")
181 .setStyle(ButtonStyle.Primary)
182 .setEmoji(getEmojiByName("ICONS.EDIT", "id") as APIMessageComponentEmoji),
183 new ButtonBuilder()
184 .setCustomId("reorder")
185 .setLabel("Reorder")
186 .setDisabled(!isAdmin)
187 .setStyle(ButtonStyle.Primary)
188 .setEmoji(getEmojiByName("ICONS.REORDER", "id") as APIMessageComponentEmoji),
189 new ButtonBuilder()
190 .setCustomId("retainPrevious")
191 .setLabel("Retain Previous")
192 .setStyle(current.retainPrevious ? ButtonStyle.Success : ButtonStyle.Danger)
193 .setEmoji(getEmojiByName("CONTROL." + (current.retainPrevious ? "TICK" : "CROSS"), "id") as APIMessageComponentEmoji),
194 new ButtonBuilder()
195 .setCustomId("nullable")
196 .setLabel(`Role ${current.nullable ? "Not " : ""}Required`)
197 .setStyle(current.nullable ? ButtonStyle.Success : ButtonStyle.Danger)
198 .setEmoji(getEmojiByName("CONTROL." + (current.nullable ? "TICK" : "CROSS"), "id") as APIMessageComponentEmoji)
199 );
200
PineaFanb0d0c242023-02-05 10:59:45 +0000201 const allowed: boolean[] = [];
TheCodedProfb5e9d552023-01-29 15:43:26 -0500202 for (const role of current.track) {
203 const disabled: boolean =
204 roles.get(role)!.position >= (interaction.member as GuildMember).roles.highest.position;
205 allowed.push(disabled)
206 }
TheCodedProf4f79da12023-01-31 16:50:37 -0500207 const mapped = current.track.map(role => roles.find(aRole => aRole.id === role)!);
TheCodedProfb5e9d552023-01-29 15:43:26 -0500208
209 const embed = new EmojiEmbed()
210 .setTitle("Tracks")
211 .setDescription(
212 `**Currently Editing:** ${current.name}\n\n` +
TheCodedProf4f79da12023-01-31 16:50:37 -0500213 `${getEmojiByName("CONTROL." + (current.nullable ? "CROSS" : "TICK"))} Members ${current.nullable ? "don't " : ""}need a role in this track\n` +
214 `${getEmojiByName("CONTROL." + (current.retainPrevious ? "TICK" : "CROSS"))} Members ${current.retainPrevious ? "" : "don't "}keep all roles below their current highest\n\n` +
215 createVerticalTrack(
216 mapped.map(role => renderRole(role)), new Array(current.track.length).fill(false), allowed)
TheCodedProfb5e9d552023-01-29 15:43:26 -0500217 )
TheCodedProf4f79da12023-01-31 16:50:37 -0500218 .setStatus("Success")
TheCodedProfb5e9d552023-01-29 15:43:26 -0500219
TheCodedProf4f79da12023-01-31 16:50:37 -0500220 interaction.editReply({embeds: [embed], components: [roleSelect, selectMenu, buttons]});
TheCodedProfb5e9d552023-01-29 15:43:26 -0500221
222 let out: ButtonInteraction | RoleSelectMenuInteraction | StringSelectMenuInteraction | null;
223
224 try {
225 out = await message.awaitMessageComponent({
226 filter: (i) => i.channel!.id === interaction.channel!.id,
227 time: 300000
228 }) as ButtonInteraction | RoleSelectMenuInteraction | StringSelectMenuInteraction | null;
229 } catch (e) {
230 console.error(e);
231 out = null;
232 }
233
234 if(!out) return;
235 if (out.isButton()) {
236 out.deferUpdate();
237 switch(out.customId) {
PineaFanb0d0c242023-02-05 10:59:45 +0000238 case "back": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500239 closed = true;
240 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000241 }
242 case "edit": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500243 current.name = (await editName(out, interaction, message, current.name))!;
244 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000245 }
246 case "reorder": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500247 current.track = (await reorderTracks(out, message, roles, current.track))!;
TheCodedProf4f79da12023-01-31 16:50:37 -0500248 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000249 }
250 case "retainPrevious": {
TheCodedProf4f79da12023-01-31 16:50:37 -0500251 current.retainPrevious = !current.retainPrevious;
252 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000253 }
254 case "nullable": {
TheCodedProf4f79da12023-01-31 16:50:37 -0500255 current.nullable = !current.nullable;
256 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000257 }
TheCodedProfb5e9d552023-01-29 15:43:26 -0500258 }
259 } else if (out.isStringSelectMenu()) {
260 out.deferUpdate();
261 switch(out.customId) {
PineaFanb0d0c242023-02-05 10:59:45 +0000262 case "removeRole": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500263 const index = current.track.findIndex(v => v === editableRoles[parseInt((out! as StringSelectMenuInteraction).values![0]!)]);
264 current.track.splice(index, 1);
265 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000266 }
TheCodedProfb5e9d552023-01-29 15:43:26 -0500267 }
268 } else {
269 switch(out.customId) {
PineaFanb0d0c242023-02-05 10:59:45 +0000270 case "addRole": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500271 const role = out.values![0]!;
272 if(!current.track.includes(role)) {
273 current.track.push(role);
274 }
275 out.reply({content: "That role is already on this track", ephemeral: true})
276 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000277 }
TheCodedProfb5e9d552023-01-29 15:43:26 -0500278 }
279 }
280
281 } while(!closed);
282 return current;
283}
TheCodedProfa112f612023-01-28 18:06:45 -0500284
285const callback = async (interaction: CommandInteraction) => {
286
TheCodedProfb5e9d552023-01-29 15:43:26 -0500287 const m = await interaction.reply({embeds: LoadingEmbed, fetchReply: true, ephemeral: true})
288 const config = await client.database.guilds.read(interaction.guild!.id);
289 const tracks: ObjectSchema[] = config.tracks;
290 const roles = await interaction.guild!.roles.fetch();
TheCodedProfb5e9d552023-01-29 15:43:26 -0500291
292 let page = 0;
293 let closed = false;
294 let modified = false;
295
296 do {
297 const embed = new EmojiEmbed()
298 .setTitle("Track Settings")
299 .setEmoji("TRACKS.ICON")
300 .setStatus("Success");
301 const noTracks = config.tracks.length === 0;
302 let current: ObjectSchema;
303
304 const pageSelect = new StringSelectMenuBuilder()
305 .setCustomId("page")
306 .setPlaceholder("Select a track to manage");
307 const actionSelect = new StringSelectMenuBuilder()
308 .setCustomId("action")
309 .setPlaceholder("Perform an action")
310 .addOptions(
311 new StringSelectMenuOptionBuilder()
312 .setLabel("Edit")
313 .setDescription("Edit this track")
314 .setValue("edit")
315 .setEmoji(getEmojiByName("ICONS.EDIT", "id") as APIMessageComponentEmoji),
316 new StringSelectMenuOptionBuilder()
317 .setLabel("Delete")
318 .setDescription("Delete this track")
319 .setValue("delete")
320 .setEmoji(getEmojiByName("TICKETS.ISSUE", "id") as APIMessageComponentEmoji)
321 );
322 const buttonRow = new ActionRowBuilder<ButtonBuilder>()
323 .addComponents(
324 new ButtonBuilder()
325 .setCustomId("back")
326 .setStyle(ButtonStyle.Primary)
327 .setEmoji(getEmojiByName("CONTROL.LEFT", "id") as APIMessageComponentEmoji)
328 .setDisabled(page === 0),
329 new ButtonBuilder()
330 .setCustomId("next")
331 .setEmoji(getEmojiByName("CONTROL.RIGHT", "id") as APIMessageComponentEmoji)
332 .setStyle(ButtonStyle.Primary)
333 .setDisabled(page === Object.keys(tracks).length - 1),
334 new ButtonBuilder()
335 .setCustomId("add")
336 .setLabel("New Track")
337 .setEmoji(getEmojiByName("TICKETS.SUGGESTION", "id") as APIMessageComponentEmoji)
338 .setStyle(ButtonStyle.Secondary)
339 .setDisabled(Object.keys(tracks).length >= 24),
340 new ButtonBuilder()
341 .setCustomId("save")
342 .setLabel("Save")
343 .setEmoji(getEmojiByName("ICONS.SAVE", "id") as APIMessageComponentEmoji)
344 .setStyle(ButtonStyle.Success)
345 .setDisabled(!modified),
346 );
347 if(noTracks) {
348 embed.setDescription("No tracks have been set up yet. Use the button below to add one.\n\n" +
349 createPageIndicator(1, 1, undefined, true)
350 );
351 pageSelect.setDisabled(true);
352 actionSelect.setDisabled(true);
353 pageSelect.addOptions(new StringSelectMenuOptionBuilder()
354 .setLabel("No tracks")
355 .setValue("none")
356 );
357 } else {
358 page = Math.min(page, Object.keys(tracks).length - 1);
359 current = tracks[page]!;
TheCodedProf4f79da12023-01-31 16:50:37 -0500360 const mapped = current.track.map(role => roles.find(aRole => aRole.id === role)!);
TheCodedProfb5e9d552023-01-29 15:43:26 -0500361 embed.setDescription(`**Currently Editing:** ${current.name}\n\n` +
362 `${getEmojiByName("CONTROL." + (current.nullable ? "CROSS" : "TICK"))} Members ${current.nullable ? "don't " : ""}need a role in this track\n` +
363 `${getEmojiByName("CONTROL." + (current.retainPrevious ? "TICK" : "CROSS"))} Members ${current.retainPrevious ? "" : "don't "}keep all roles below their current highest\n\n` +
TheCodedProf4f79da12023-01-31 16:50:37 -0500364 createVerticalTrack(mapped.map(role => renderRole(role)), new Array(current.track.length).fill(false)) +
TheCodedProfb5e9d552023-01-29 15:43:26 -0500365 `\n${createPageIndicator(config.tracks.length, page)}`
366 );
367
368 pageSelect.addOptions(
369 tracks.map((key: ObjectSchema, index) => {
370 return new StringSelectMenuOptionBuilder()
371 .setLabel(ellipsis(key.name, 50))
372 .setDescription(ellipsis(roles.get(key.track[0]!)?.name!, 50))
373 .setValue(index.toString());
374 })
375 );
376
377 }
378
379 await interaction.editReply({embeds: [embed], components: [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(actionSelect), new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(pageSelect), buttonRow]});
380 let i: StringSelectMenuInteraction | ButtonInteraction;
381 try {
382 i = await m.awaitMessageComponent({ time: 300000, filter: (i) => i.user.id === interaction.user.id && i.message.id === m.id && i.channelId === interaction.channelId}) as ButtonInteraction | StringSelectMenuInteraction;
383 } catch (e) {
384 closed = true;
PineaFanb0d0c242023-02-05 10:59:45 +0000385 continue;
TheCodedProfb5e9d552023-01-29 15:43:26 -0500386 }
387
388 await i.deferUpdate();
389 if (i.isButton()) {
390 switch (i.customId) {
PineaFanb0d0c242023-02-05 10:59:45 +0000391 case "back": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500392 page--;
393 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000394 }
395 case "next": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500396 page++;
397 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000398 }
399 case "add": {
400 const newPage = await editTrack(i, m, roles)
TheCodedProfb5e9d552023-01-29 15:43:26 -0500401 if(!newPage) break;
402 tracks.push();
403 page = tracks.length - 1;
404 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000405 }
406 case "save": {
TheCodedProf4f79da12023-01-31 16:50:37 -0500407 client.database.guilds.write(interaction.guild!.id, {tracks: tracks});
TheCodedProfb5e9d552023-01-29 15:43:26 -0500408 modified = false;
409 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000410 }
TheCodedProfb5e9d552023-01-29 15:43:26 -0500411 }
412 } else if (i.isStringSelectMenu()) {
413 switch (i.customId) {
PineaFanb0d0c242023-02-05 10:59:45 +0000414 case "action": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500415 switch(i.values[0]) {
PineaFanb0d0c242023-02-05 10:59:45 +0000416 case "edit": {
417 const edited = await editTrack(i, m, roles, current!);
TheCodedProfb5e9d552023-01-29 15:43:26 -0500418 if(!edited) break;
419 tracks[page] = edited;
420 modified = true;
421 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000422 }
423 case "delete": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500424 if(page === 0 && tracks.keys.length - 1 > 0) page++;
425 else page--;
426 tracks.splice(page, 1);
427 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000428 }
TheCodedProfb5e9d552023-01-29 15:43:26 -0500429 }
430 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000431 }
432 case "page": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500433 page = parseInt(i.values[0]!);
434 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000435 }
TheCodedProfb5e9d552023-01-29 15:43:26 -0500436 }
437 }
438
TheCodedProf01cba762023-02-18 15:55:05 -0500439 } while (!closed);
440 await interaction.deleteReply()
TheCodedProfa112f612023-01-28 18:06:45 -0500441}
442
443const check = (interaction: CommandInteraction, _partial: boolean = false) => {
444 const member = interaction.member as GuildMember;
445 if (!member.permissions.has("ManageRoles"))
446 return "You must have the *Manage Server* permission to use this command";
447 return true;
448};
449
450export { command };
451export { callback };
452export { check };