blob: 4e27d687a9f2d8ac08c8dfd6ddb700b3c2e92814 [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")
38 .setPlaceholder("Name here...") // TODO: Make better placeholder
39 .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 {
66 out = await modalInteractionCollector(
67 m,
68 (m) => m.channel!.id === interaction.channel!.id,
69 (_) => true
70 ) as ModalSubmitInteraction | null;
71 } catch (e) {
72 console.error(e);
73 out = null;
74 }
75 if(!out) return name;
76 if (out.isButton()) return name;
TheCodedProfb5e9d552023-01-29 15:43:26 -050077 name = out.fields.fields.find((f) => f.customId === "name")?.value ?? name;
78 return name
79
80}
81
82const reorderTracks = async (interaction: ButtonInteraction, m: Message, roles: Collection<string, Role>, currentObj: string[]) => {
PineaFanb0d0c242023-02-05 10:59:45 +000083 const reorderRow = new ActionRowBuilder<StringSelectMenuBuilder>()
TheCodedProfb5e9d552023-01-29 15:43:26 -050084 .addComponents(
85 new StringSelectMenuBuilder()
86 .setCustomId("reorder")
87 .setPlaceholder("Select all roles in the order you want users to gain them (Lowest to highest rank).")
88 .setMinValues(currentObj.length)
89 .setMaxValues(currentObj.length)
90 .addOptions(
91 currentObj.map((o, i) => new StringSelectMenuOptionBuilder()
92 .setLabel(roles.get(o)!.name)
93 .setValue(i.toString())
94 )
95 )
96 );
PineaFanb0d0c242023-02-05 10:59:45 +000097 const buttonRow = new ActionRowBuilder<ButtonBuilder>()
TheCodedProfb5e9d552023-01-29 15:43:26 -050098 .addComponents(
99 new ButtonBuilder()
100 .setCustomId("back")
101 .setLabel("Back")
102 .setStyle(ButtonStyle.Secondary)
103 .setEmoji(getEmojiByName("CONTROL.LEFT", "id") as APIMessageComponentEmoji)
104 )
105 await interaction.editReply({
106 embeds: [
107 new EmojiEmbed()
108 .setTitle("Tracks")
109 .setDescription("Select all roles in the order you want users to gain them (Lowest to highest rank).")
110 .setStatus("Success")
111 ],
112 components: [reorderRow, buttonRow]
113 });
114 let out: StringSelectMenuInteraction | ButtonInteraction | null;
115 try {
116 out = await m.awaitMessageComponent({
117 filter: (i) => i.channel!.id === interaction.channel!.id,
118 time: 300000
119 }) as StringSelectMenuInteraction | ButtonInteraction | null;
120 } catch (e) {
121 console.error(e);
122 out = null;
123 }
124 if(!out) return;
125 out.deferUpdate();
126 if (out.isButton()) return;
TheCodedProfb5e9d552023-01-29 15:43:26 -0500127 const values = out.values;
128
129 const newOrder: string[] = currentObj.map((_, i) => {
130 const index = values.findIndex(v => v === i.toString());
131 return currentObj[index];
132 }) as string[];
133
134 return newOrder;
135}
136
137const editTrack = async (interaction: ButtonInteraction | StringSelectMenuInteraction, message: Message, roles: Collection<string, Role>, current?: ObjectSchema) => {
TheCodedProf4f79da12023-01-31 16:50:37 -0500138 const isAdmin = (interaction.member!.permissions as PermissionsBitField).has("Administrator");
TheCodedProfb5e9d552023-01-29 15:43:26 -0500139 if(!current) {
140 current = {
141 name: "",
142 retainPrevious: false,
143 nullable: false,
144 track: [],
145 manageableBy: []
146 }
147 }
TheCodedProf4f79da12023-01-31 16:50:37 -0500148
TheCodedProfb5e9d552023-01-29 15:43:26 -0500149 const roleSelect = new ActionRowBuilder<RoleSelectMenuBuilder>()
150 .addComponents(
151 new RoleSelectMenuBuilder()
152 .setCustomId("addRole")
153 .setPlaceholder("Select a role to add")
TheCodedProf4f79da12023-01-31 16:50:37 -0500154 .setDisabled(!isAdmin)
TheCodedProfb5e9d552023-01-29 15:43:26 -0500155 );
156 let closed = false;
157 do {
158 const editableRoles: string[] = current.track.map((r) => {
TheCodedProf4f79da12023-01-31 16:50:37 -0500159 if(!(roles.get(r)!.position >= (interaction.member as GuildMember).roles.highest.position)) return roles.get(r)!.name;
TheCodedProfb5e9d552023-01-29 15:43:26 -0500160 }).filter(v => v !== undefined) as string[];
161 const selectMenu = new ActionRowBuilder<StringSelectMenuBuilder>()
162 .addComponents(
163 new StringSelectMenuBuilder()
164 .setCustomId("removeRole")
165 .setPlaceholder("Select a role to remove")
TheCodedProf4f79da12023-01-31 16:50:37 -0500166 .setDisabled(!isAdmin)
TheCodedProfb5e9d552023-01-29 15:43:26 -0500167 .addOptions(
168 editableRoles.map((r, i) => {
169 return new StringSelectMenuOptionBuilder()
170 .setLabel(r)
171 .setValue(i.toString())}
172 )
173 )
174 );
TheCodedProf4f79da12023-01-31 16:50:37 -0500175 const buttons = new ActionRowBuilder<ButtonBuilder>()
176 .addComponents(
177 new ButtonBuilder()
178 .setCustomId("back")
179 .setLabel("Back")
180 .setStyle(ButtonStyle.Secondary)
181 .setEmoji(getEmojiByName("CONTROL.LEFT", "id") as APIMessageComponentEmoji),
182 new ButtonBuilder()
183 .setCustomId("edit")
184 .setLabel("Edit Name")
185 .setStyle(ButtonStyle.Primary)
186 .setEmoji(getEmojiByName("ICONS.EDIT", "id") as APIMessageComponentEmoji),
187 new ButtonBuilder()
188 .setCustomId("reorder")
189 .setLabel("Reorder")
190 .setDisabled(!isAdmin)
191 .setStyle(ButtonStyle.Primary)
192 .setEmoji(getEmojiByName("ICONS.REORDER", "id") as APIMessageComponentEmoji),
193 new ButtonBuilder()
194 .setCustomId("retainPrevious")
195 .setLabel("Retain Previous")
196 .setStyle(current.retainPrevious ? ButtonStyle.Success : ButtonStyle.Danger)
197 .setEmoji(getEmojiByName("CONTROL." + (current.retainPrevious ? "TICK" : "CROSS"), "id") as APIMessageComponentEmoji),
198 new ButtonBuilder()
199 .setCustomId("nullable")
200 .setLabel(`Role ${current.nullable ? "Not " : ""}Required`)
201 .setStyle(current.nullable ? ButtonStyle.Success : ButtonStyle.Danger)
202 .setEmoji(getEmojiByName("CONTROL." + (current.nullable ? "TICK" : "CROSS"), "id") as APIMessageComponentEmoji)
203 );
204
PineaFanb0d0c242023-02-05 10:59:45 +0000205 const allowed: boolean[] = [];
TheCodedProfb5e9d552023-01-29 15:43:26 -0500206 for (const role of current.track) {
207 const disabled: boolean =
208 roles.get(role)!.position >= (interaction.member as GuildMember).roles.highest.position;
209 allowed.push(disabled)
210 }
TheCodedProf4f79da12023-01-31 16:50:37 -0500211 const mapped = current.track.map(role => roles.find(aRole => aRole.id === role)!);
TheCodedProfb5e9d552023-01-29 15:43:26 -0500212
213 const embed = new EmojiEmbed()
214 .setTitle("Tracks")
215 .setDescription(
216 `**Currently Editing:** ${current.name}\n\n` +
TheCodedProf4f79da12023-01-31 16:50:37 -0500217 `${getEmojiByName("CONTROL." + (current.nullable ? "CROSS" : "TICK"))} Members ${current.nullable ? "don't " : ""}need a role in this track\n` +
218 `${getEmojiByName("CONTROL." + (current.retainPrevious ? "TICK" : "CROSS"))} Members ${current.retainPrevious ? "" : "don't "}keep all roles below their current highest\n\n` +
219 createVerticalTrack(
220 mapped.map(role => renderRole(role)), new Array(current.track.length).fill(false), allowed)
TheCodedProfb5e9d552023-01-29 15:43:26 -0500221 )
TheCodedProf4f79da12023-01-31 16:50:37 -0500222 .setStatus("Success")
TheCodedProfb5e9d552023-01-29 15:43:26 -0500223
TheCodedProf4f79da12023-01-31 16:50:37 -0500224 interaction.editReply({embeds: [embed], components: [roleSelect, selectMenu, buttons]});
TheCodedProfb5e9d552023-01-29 15:43:26 -0500225
226 let out: ButtonInteraction | RoleSelectMenuInteraction | StringSelectMenuInteraction | null;
227
228 try {
229 out = await message.awaitMessageComponent({
230 filter: (i) => i.channel!.id === interaction.channel!.id,
231 time: 300000
232 }) as ButtonInteraction | RoleSelectMenuInteraction | StringSelectMenuInteraction | null;
233 } catch (e) {
234 console.error(e);
235 out = null;
236 }
237
238 if(!out) return;
239 if (out.isButton()) {
240 out.deferUpdate();
241 switch(out.customId) {
PineaFanb0d0c242023-02-05 10:59:45 +0000242 case "back": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500243 closed = true;
244 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000245 }
246 case "edit": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500247 current.name = (await editName(out, interaction, message, current.name))!;
248 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000249 }
250 case "reorder": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500251 current.track = (await reorderTracks(out, message, roles, current.track))!;
TheCodedProf4f79da12023-01-31 16:50:37 -0500252 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000253 }
254 case "retainPrevious": {
TheCodedProf4f79da12023-01-31 16:50:37 -0500255 current.retainPrevious = !current.retainPrevious;
256 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000257 }
258 case "nullable": {
TheCodedProf4f79da12023-01-31 16:50:37 -0500259 current.nullable = !current.nullable;
260 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000261 }
TheCodedProfb5e9d552023-01-29 15:43:26 -0500262 }
263 } else if (out.isStringSelectMenu()) {
264 out.deferUpdate();
265 switch(out.customId) {
PineaFanb0d0c242023-02-05 10:59:45 +0000266 case "removeRole": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500267 const index = current.track.findIndex(v => v === editableRoles[parseInt((out! as StringSelectMenuInteraction).values![0]!)]);
268 current.track.splice(index, 1);
269 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000270 }
TheCodedProfb5e9d552023-01-29 15:43:26 -0500271 }
272 } else {
273 switch(out.customId) {
PineaFanb0d0c242023-02-05 10:59:45 +0000274 case "addRole": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500275 const role = out.values![0]!;
276 if(!current.track.includes(role)) {
277 current.track.push(role);
278 }
279 out.reply({content: "That role is already on this track", ephemeral: true})
280 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000281 }
TheCodedProfb5e9d552023-01-29 15:43:26 -0500282 }
283 }
284
285 } while(!closed);
286 return current;
287}
TheCodedProfa112f612023-01-28 18:06:45 -0500288
289const callback = async (interaction: CommandInteraction) => {
290
TheCodedProfb5e9d552023-01-29 15:43:26 -0500291 const m = await interaction.reply({embeds: LoadingEmbed, fetchReply: true, ephemeral: true})
292 const config = await client.database.guilds.read(interaction.guild!.id);
293 const tracks: ObjectSchema[] = config.tracks;
294 const roles = await interaction.guild!.roles.fetch();
TheCodedProfb5e9d552023-01-29 15:43:26 -0500295
296 let page = 0;
297 let closed = false;
298 let modified = false;
299
300 do {
301 const embed = new EmojiEmbed()
302 .setTitle("Track Settings")
303 .setEmoji("TRACKS.ICON")
304 .setStatus("Success");
305 const noTracks = config.tracks.length === 0;
306 let current: ObjectSchema;
307
308 const pageSelect = new StringSelectMenuBuilder()
309 .setCustomId("page")
310 .setPlaceholder("Select a track to manage");
311 const actionSelect = new StringSelectMenuBuilder()
312 .setCustomId("action")
313 .setPlaceholder("Perform an action")
314 .addOptions(
315 new StringSelectMenuOptionBuilder()
316 .setLabel("Edit")
317 .setDescription("Edit this track")
318 .setValue("edit")
319 .setEmoji(getEmojiByName("ICONS.EDIT", "id") as APIMessageComponentEmoji),
320 new StringSelectMenuOptionBuilder()
321 .setLabel("Delete")
322 .setDescription("Delete this track")
323 .setValue("delete")
324 .setEmoji(getEmojiByName("TICKETS.ISSUE", "id") as APIMessageComponentEmoji)
325 );
326 const buttonRow = new ActionRowBuilder<ButtonBuilder>()
327 .addComponents(
328 new ButtonBuilder()
329 .setCustomId("back")
330 .setStyle(ButtonStyle.Primary)
331 .setEmoji(getEmojiByName("CONTROL.LEFT", "id") as APIMessageComponentEmoji)
332 .setDisabled(page === 0),
333 new ButtonBuilder()
334 .setCustomId("next")
335 .setEmoji(getEmojiByName("CONTROL.RIGHT", "id") as APIMessageComponentEmoji)
336 .setStyle(ButtonStyle.Primary)
337 .setDisabled(page === Object.keys(tracks).length - 1),
338 new ButtonBuilder()
339 .setCustomId("add")
340 .setLabel("New Track")
341 .setEmoji(getEmojiByName("TICKETS.SUGGESTION", "id") as APIMessageComponentEmoji)
342 .setStyle(ButtonStyle.Secondary)
343 .setDisabled(Object.keys(tracks).length >= 24),
344 new ButtonBuilder()
345 .setCustomId("save")
346 .setLabel("Save")
347 .setEmoji(getEmojiByName("ICONS.SAVE", "id") as APIMessageComponentEmoji)
348 .setStyle(ButtonStyle.Success)
349 .setDisabled(!modified),
350 );
351 if(noTracks) {
352 embed.setDescription("No tracks have been set up yet. Use the button below to add one.\n\n" +
353 createPageIndicator(1, 1, undefined, true)
354 );
355 pageSelect.setDisabled(true);
356 actionSelect.setDisabled(true);
357 pageSelect.addOptions(new StringSelectMenuOptionBuilder()
358 .setLabel("No tracks")
359 .setValue("none")
360 );
361 } else {
362 page = Math.min(page, Object.keys(tracks).length - 1);
363 current = tracks[page]!;
TheCodedProf4f79da12023-01-31 16:50:37 -0500364 const mapped = current.track.map(role => roles.find(aRole => aRole.id === role)!);
TheCodedProfb5e9d552023-01-29 15:43:26 -0500365 embed.setDescription(`**Currently Editing:** ${current.name}\n\n` +
366 `${getEmojiByName("CONTROL." + (current.nullable ? "CROSS" : "TICK"))} Members ${current.nullable ? "don't " : ""}need a role in this track\n` +
367 `${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 -0500368 createVerticalTrack(mapped.map(role => renderRole(role)), new Array(current.track.length).fill(false)) +
TheCodedProfb5e9d552023-01-29 15:43:26 -0500369 `\n${createPageIndicator(config.tracks.length, page)}`
370 );
371
372 pageSelect.addOptions(
373 tracks.map((key: ObjectSchema, index) => {
374 return new StringSelectMenuOptionBuilder()
375 .setLabel(ellipsis(key.name, 50))
376 .setDescription(ellipsis(roles.get(key.track[0]!)?.name!, 50))
377 .setValue(index.toString());
378 })
379 );
380
381 }
382
383 await interaction.editReply({embeds: [embed], components: [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(actionSelect), new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(pageSelect), buttonRow]});
384 let i: StringSelectMenuInteraction | ButtonInteraction;
385 try {
386 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;
387 } catch (e) {
388 closed = true;
PineaFanb0d0c242023-02-05 10:59:45 +0000389 continue;
TheCodedProfb5e9d552023-01-29 15:43:26 -0500390 }
391
392 await i.deferUpdate();
393 if (i.isButton()) {
394 switch (i.customId) {
PineaFanb0d0c242023-02-05 10:59:45 +0000395 case "back": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500396 page--;
397 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000398 }
399 case "next": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500400 page++;
401 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000402 }
403 case "add": {
404 const newPage = await editTrack(i, m, roles)
TheCodedProfb5e9d552023-01-29 15:43:26 -0500405 if(!newPage) break;
406 tracks.push();
407 page = tracks.length - 1;
408 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000409 }
410 case "save": {
TheCodedProf4f79da12023-01-31 16:50:37 -0500411 client.database.guilds.write(interaction.guild!.id, {tracks: tracks});
TheCodedProfb5e9d552023-01-29 15:43:26 -0500412 modified = false;
413 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000414 }
TheCodedProfb5e9d552023-01-29 15:43:26 -0500415 }
416 } else if (i.isStringSelectMenu()) {
417 switch (i.customId) {
PineaFanb0d0c242023-02-05 10:59:45 +0000418 case "action": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500419 switch(i.values[0]) {
PineaFanb0d0c242023-02-05 10:59:45 +0000420 case "edit": {
421 const edited = await editTrack(i, m, roles, current!);
TheCodedProfb5e9d552023-01-29 15:43:26 -0500422 if(!edited) break;
423 tracks[page] = edited;
424 modified = true;
425 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000426 }
427 case "delete": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500428 if(page === 0 && tracks.keys.length - 1 > 0) page++;
429 else page--;
430 tracks.splice(page, 1);
431 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000432 }
TheCodedProfb5e9d552023-01-29 15:43:26 -0500433 }
434 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000435 }
436 case "page": {
TheCodedProfb5e9d552023-01-29 15:43:26 -0500437 page = parseInt(i.values[0]!);
438 break;
PineaFanb0d0c242023-02-05 10:59:45 +0000439 }
TheCodedProfb5e9d552023-01-29 15:43:26 -0500440 }
441 }
442
443 } while (!closed)
TheCodedProfa112f612023-01-28 18:06:45 -0500444}
445
446const check = (interaction: CommandInteraction, _partial: boolean = false) => {
447 const member = interaction.member as GuildMember;
448 if (!member.permissions.has("ManageRoles"))
449 return "You must have the *Manage Server* permission to use this command";
450 return true;
451};
452
453export { command };
454export { callback };
455export { check };