blob: d9d485d37f5ea4c4be527a98d9daf348c89a2703 [file] [log] [blame]
Samuel Shuert27bf3cd2023-03-03 15:51:25 -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";
2import client from "../../utils/client.js";
3import 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";
9
10const { renderRole } = client.logger
11
12const command = (builder: SlashCommandSubcommandBuilder) =>
13 builder
14 .setName("tracks")
15 .setDescription("Manage the tracks for the server")
16
17interface ObjectSchema {
18 name: string;
19 retainPrevious: boolean;
20 nullable: boolean;
21 track: string[];
22 manageableBy: string[];
23}
24
25
26const 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("The name of the track (e.g. Moderators)")
39 .setStyle(TextInputStyle.Short)
40 .setValue(name)
41 .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(m, interaction.user) as ModalSubmitInteraction | null;
67 } catch (e) {
68 console.error(e);
69 out = null;
70 }
71 if(!out) return name;
72 if (out.isButton()) return name;
73 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[]) => {
79 const reorderRow = new ActionRowBuilder<StringSelectMenuBuilder>()
80 .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 );
93 const buttonRow = new ActionRowBuilder<ButtonBuilder>()
94 .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;
123 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) => {
134 const isAdmin = (interaction.member!.permissions as PermissionsBitField).has("Administrator");
135 if(!current) {
136 current = {
137 name: "",
138 retainPrevious: false,
139 nullable: false,
140 track: [],
141 manageableBy: []
142 }
143 }
144
145 const roleSelect = new ActionRowBuilder<RoleSelectMenuBuilder>()
146 .addComponents(
147 new RoleSelectMenuBuilder()
148 .setCustomId("addRole")
149 .setPlaceholder("Select a role to add")
150 .setDisabled(!isAdmin)
151 );
152 let closed = false;
153 do {
154 const editableRoles: string[] = current.track.map((r) => {
155 if(!(roles.get(r)!.position >= (interaction.member as GuildMember).roles.highest.position) || interaction.user.id === interaction.guild?.ownerId) return roles.get(r)!.name;
156 }).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")
162 .setDisabled(!isAdmin)
163 .addOptions(
164 editableRoles.map((r, i) => {
165 return new StringSelectMenuOptionBuilder()
166 .setLabel(r)
167 .setValue(i.toString())}
168 )
169 )
170 );
171 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
201 const allowed: boolean[] = [];
202 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 }
207 const mapped = current.track.map(role => roles.find(aRole => aRole.id === role)!);
208
209 const embed = new EmojiEmbed()
210 .setTitle("Tracks")
211 .setDescription(
212 `**Currently Editing:** ${current.name}\n\n` +
213 `${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)
217 )
218 .setStatus("Success")
219
220 const comps: ActionRowBuilder<RoleSelectMenuBuilder | ButtonBuilder | StringSelectMenuBuilder>[] = [roleSelect, buttons];
221 if(current.track.length >= 1) comps.splice(1, 0, selectMenu);
222
223 interaction.editReply({embeds: [embed], components: comps});
224
225 let out: ButtonInteraction | RoleSelectMenuInteraction | StringSelectMenuInteraction | null;
226
227 try {
228 out = await message.awaitMessageComponent({
229 filter: (i) => i.channel!.id === interaction.channel!.id,
230 time: 300000
231 }) as ButtonInteraction | RoleSelectMenuInteraction | StringSelectMenuInteraction | null;
232 } catch (e) {
233 console.error(e);
234 out = null;
235 }
236
237 if(!out) return;
238 if (out.isButton()) {
239 switch(out.customId) {
240 case "back": {
241 out.deferUpdate();
242 closed = true;
243 break;
244 }
245 case "edit": {
246 current.name = (await editName(out, interaction, message, current.name))!;
247 break;
248 }
249 case "reorder": {
250 out.deferUpdate();
251 current.track = (await reorderTracks(out, message, roles, current.track))!;
252 break;
253 }
254 case "retainPrevious": {
255 out.deferUpdate();
256 current.retainPrevious = !current.retainPrevious;
257 break;
258 }
259 case "nullable": {
260 out.deferUpdate();
261 current.nullable = !current.nullable;
262 break;
263 }
264 }
265 } else if (out.isStringSelectMenu()) {
266 out.deferUpdate();
267 switch(out.customId) {
268 case "removeRole": {
269 const index = current.track.findIndex(v => v === editableRoles[parseInt((out! as StringSelectMenuInteraction).values![0]!)]);
270 current.track.splice(index, 1);
271 break;
272 }
273 }
274 } else {
275 switch(out.customId) {
276 case "addRole": {
277 const role = out.values![0]!;
278 if(!current.track.includes(role)) {
279 current.track.push(role);
280 } else {
281 out.reply({content: "That role is already on this track", ephemeral: true})
282 }
283 break;
284 }
285 }
286 }
287
288 } while(!closed);
289 return current;
290}
291
292const callback = async (interaction: CommandInteraction) => {
293
294 const m = await interaction.reply({embeds: LoadingEmbed, fetchReply: true, ephemeral: true})
295 const config = await client.database.guilds.read(interaction.guild!.id);
296 const tracks: ObjectSchema[] = config.tracks;
297 const roles = await interaction.guild!.roles.fetch();
298
299 let page = 0;
300 let closed = false;
301 let modified = false;
302
303 do {
304 const embed = new EmojiEmbed()
305 .setTitle("Track Settings")
306 .setEmoji("TRACKS.ICON")
307 .setStatus("Success");
308 const noTracks = config.tracks.length === 0;
309 let current: ObjectSchema;
310
311 const pageSelect = new StringSelectMenuBuilder()
312 .setCustomId("page")
313 .setPlaceholder("Select a track to manage");
314 const actionSelect = new StringSelectMenuBuilder()
315 .setCustomId("action")
316 .setPlaceholder("Perform an action")
317 .addOptions(
318 new StringSelectMenuOptionBuilder()
319 .setLabel("Edit")
320 .setDescription("Edit this track")
321 .setValue("edit")
322 .setEmoji(getEmojiByName("ICONS.EDIT", "id") as APIMessageComponentEmoji),
323 new StringSelectMenuOptionBuilder()
324 .setLabel("Delete")
325 .setDescription("Delete this track")
326 .setValue("delete")
327 .setEmoji(getEmojiByName("TICKETS.ISSUE", "id") as APIMessageComponentEmoji)
328 );
329 const buttonRow = new ActionRowBuilder<ButtonBuilder>()
330 .addComponents(
331 new ButtonBuilder()
332 .setCustomId("back")
333 .setStyle(ButtonStyle.Primary)
334 .setEmoji(getEmojiByName("CONTROL.LEFT", "id") as APIMessageComponentEmoji)
335 .setDisabled(page === 0),
336 new ButtonBuilder()
337 .setCustomId("next")
338 .setEmoji(getEmojiByName("CONTROL.RIGHT", "id") as APIMessageComponentEmoji)
339 .setStyle(ButtonStyle.Primary)
340 .setDisabled(page === tracks.length - 1),
341 new ButtonBuilder()
342 .setCustomId("add")
343 .setLabel("New Track")
344 .setEmoji(getEmojiByName("TICKETS.SUGGESTION", "id") as APIMessageComponentEmoji)
345 .setStyle(ButtonStyle.Secondary)
346 .setDisabled(Object.keys(tracks).length >= 24),
347 new ButtonBuilder()
348 .setCustomId("save")
349 .setLabel("Save")
350 .setEmoji(getEmojiByName("ICONS.SAVE", "id") as APIMessageComponentEmoji)
351 .setStyle(ButtonStyle.Success)
352 .setDisabled(!modified),
353 );
354 if(noTracks) {
355 embed.setDescription("No tracks have been set up yet. Use the button below to add one.\n\n" +
356 createPageIndicator(1, 1, undefined, true)
357 );
358 pageSelect.setDisabled(true);
359 actionSelect.setDisabled(true);
360 pageSelect.addOptions(new StringSelectMenuOptionBuilder()
361 .setLabel("No tracks")
362 .setValue("none")
363 );
364 } else {
365 page = Math.min(page, Object.keys(tracks).length - 1);
366 current = tracks[page]!;
367 const mapped = current.track.map(role => roles.find(aRole => aRole.id === role)!);
368 embed.setDescription(`**Currently Editing:** ${current.name}\n\n` +
369 `${getEmojiByName("CONTROL." + (current.nullable ? "CROSS" : "TICK"))} Members ${current.nullable ? "don't " : ""}need a role in this track\n` +
370 `${getEmojiByName("CONTROL." + (current.retainPrevious ? "TICK" : "CROSS"))} Members ${current.retainPrevious ? "" : "don't "}keep all roles below their current highest\n\n` +
371 createVerticalTrack(mapped.map(role => renderRole(role)), new Array(current.track.length).fill(false)) +
372 `\n${createPageIndicator(config.tracks.length, page)}`
373 );
374
375 pageSelect.addOptions(
376 tracks.map((key: ObjectSchema, index) => {
377 return new StringSelectMenuOptionBuilder()
378 .setLabel(ellipsis(key.name, 50))
379 .setDescription(ellipsis(roles.get(key.track[0]!)?.name!, 50))
380 .setValue(index.toString());
381 })
382 );
383
384 }
385
386 await interaction.editReply({embeds: [embed], components: [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(actionSelect), new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(pageSelect), buttonRow]});
387 let i: StringSelectMenuInteraction | ButtonInteraction;
388 try {
389 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;
390 } catch (e) {
391 closed = true;
392 continue;
393 }
394
395 await i.deferUpdate();
396 if (i.isButton()) {
397 switch (i.customId) {
398 case "back": {
399 page--;
400 break;
401 }
402 case "next": {
403 page++;
404 break;
405 }
406 case "add": {
407 const newPage = await editTrack(i, m, roles)
408 if(!newPage) break;
409 tracks.push();
410 page = tracks.length - 1;
411 break;
412 }
413 case "save": {
414 client.database.guilds.write(interaction.guild!.id, {tracks: tracks});
415 modified = false;
416 break;
417 }
418 }
419 } else if (i.isStringSelectMenu()) {
420 switch (i.customId) {
421 case "action": {
422 switch(i.values[0]) {
423 case "edit": {
424 const edited = await editTrack(i, m, roles, current!);
425 if(!edited) break;
426 tracks[page] = edited;
427 modified = true;
428 break;
429 }
430 case "delete": {
431 if(page === 0 && tracks.keys.length - 1 > 0) page++;
432 else page--;
433 tracks.splice(page, 1);
434 break;
435 }
436 }
437 break;
438 }
439 case "page": {
440 page = parseInt(i.values[0]!);
441 break;
442 }
443 }
444 }
445
446 } while (!closed);
447 await interaction.deleteReply()
448}
449
450const check = (interaction: CommandInteraction, _partial: boolean = false) => {
451 const member = interaction.member as GuildMember;
452 if (!member.permissions.has("ManageRoles"))
453 return "You must have the *Manage Server* permission to use this command";
454 return true;
455};
456
457export { command };
458export { callback };
459export { check };