blob: b558e8745b66501f842c678272f275a0a991a2c7 [file] [log] [blame]
PineaFan5bea7e12023-01-05 21:20:04 +00001import { LinkWarningFooter, LoadingEmbed } from "./../../utils/defaultEmbeds.js";
2import Discord, { CommandInteraction, GuildMember, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
pineafan62ce1922022-08-25 20:34:45 +01003import type { SlashCommandSubcommandBuilder } from "@discordjs/builders";
pineafan4edb7762022-06-26 19:21:04 +01004import EmojiEmbed from "../../utils/generateEmojiEmbed.js";
pineafan8b4b17f2022-02-27 20:42:52 +00005import getEmojiByName from "../../utils/getEmojiByName.js";
6import confirmationMessage from "../../utils/confirmationMessage.js";
7import keyValueList from "../../utils/generateKeyValueList.js";
pineafan62ce1922022-08-25 20:34:45 +01008// @ts-expect-error
pineafan8b4b17f2022-02-27 20:42:52 +00009import humanizeDuration from "humanize-duration";
pineafan6702cef2022-06-13 17:52:37 +010010import client from "../../utils/client.js";
Skyler Grey11236ba2022-08-08 21:13:33 +010011import { areTicketsEnabled, create } from "../../actions/createModActionTicket.js";
pineafan8b4b17f2022-02-27 20:42:52 +000012
13const command = (builder: SlashCommandSubcommandBuilder) =>
14 builder
pineafan63fc5e22022-08-04 22:04:10 +010015 .setName("mute")
PineaFan538d3752023-01-12 21:48:23 +000016 // .setNameLocalizations({"ru": "silence"})
Skyler Grey11236ba2022-08-08 21:13:33 +010017 .setDescription("Mutes a member, stopping them from talking in the server")
18 .addUserOption((option) => option.setName("user").setDescription("The user to mute").setRequired(true))
Skyler Grey75ea9172022-08-06 10:22:23 +010019 .addIntegerOption((option) =>
20 option
21 .setName("days")
Skyler Grey11236ba2022-08-08 21:13:33 +010022 .setDescription("The number of days to mute the user for | Default: 0")
Skyler Grey75ea9172022-08-06 10:22:23 +010023 .setMinValue(0)
24 .setMaxValue(27)
25 .setRequired(false)
26 )
27 .addIntegerOption((option) =>
28 option
29 .setName("hours")
Skyler Grey11236ba2022-08-08 21:13:33 +010030 .setDescription("The number of hours to mute the user for | Default: 0")
Skyler Grey75ea9172022-08-06 10:22:23 +010031 .setMinValue(0)
32 .setMaxValue(23)
33 .setRequired(false)
34 )
35 .addIntegerOption((option) =>
36 option
37 .setName("minutes")
Skyler Grey11236ba2022-08-08 21:13:33 +010038 .setDescription("The number of minutes to mute the user for | Default: 0")
Skyler Grey75ea9172022-08-06 10:22:23 +010039 .setMinValue(0)
40 .setMaxValue(59)
41 .setRequired(false)
42 )
43 .addIntegerOption((option) =>
44 option
45 .setName("seconds")
Skyler Grey11236ba2022-08-08 21:13:33 +010046 .setDescription("The number of seconds to mute the user for | Default: 0")
Skyler Grey75ea9172022-08-06 10:22:23 +010047 .setMinValue(0)
48 .setMaxValue(59)
49 .setRequired(false)
50 );
pineafan8b4b17f2022-02-27 20:42:52 +000051
PineaFan5bea7e12023-01-05 21:20:04 +000052
Skyler Greyc634e2b2022-08-06 17:50:48 +010053const callback = async (interaction: CommandInteraction): Promise<unknown> => {
PineaFana00db1b2023-01-02 15:32:54 +000054 if (!interaction.guild) return;
Skyler Grey11236ba2022-08-08 21:13:33 +010055 const { log, NucleusColors, renderUser, entry, renderDelta } = client.logger;
PineaFan5bea7e12023-01-05 21:20:04 +000056 const member = interaction.options.getMember("user") as GuildMember;
57 const time: {days: number, hours: number, minutes: number, seconds: number} = {
58 days: (interaction.options.get("days")?.value as number | null) ?? 0,
59 hours: (interaction.options.get("hours")?.value as number | null) ?? 0,
60 minutes: (interaction.options.get("minutes")?.value as number | null) ?? 0,
61 seconds: (interaction.options.get("seconds")?.value as number | null) ?? 0
pineafan63fc5e22022-08-04 22:04:10 +010062 };
PineaFana00db1b2023-01-02 15:32:54 +000063 const config = await client.database.guilds.read(interaction.guild.id);
Skyler Grey11236ba2022-08-08 21:13:33 +010064 let serverSettingsDescription = config.moderation.mute.timeout ? "given a timeout" : "";
Skyler Grey75ea9172022-08-06 10:22:23 +010065 if (config.moderation.mute.role)
66 serverSettingsDescription +=
Skyler Grey11236ba2022-08-08 21:13:33 +010067 (serverSettingsDescription ? " and " : "") + `given the <@&${config.moderation.mute.role}> role`;
pineafane625d782022-05-09 18:04:32 +010068
Skyler Grey11236ba2022-08-08 21:13:33 +010069 let muteTime = time.days * 24 * 60 * 60 + time.hours * 60 * 60 + time.minutes * 60 + time.seconds;
pineafane23c4ec2022-07-27 21:56:27 +010070 if (muteTime === 0) {
PineaFan5bea7e12023-01-05 21:20:04 +000071 const m = await interaction.reply({
Skyler Grey75ea9172022-08-06 10:22:23 +010072 embeds: [
73 new EmojiEmbed()
74 .setEmoji("PUNISH.MUTE.GREEN")
75 .setTitle("Mute")
PineaFan5bea7e12023-01-05 21:20:04 +000076 .setDescription("How long should the user be muted for?")
Skyler Grey75ea9172022-08-06 10:22:23 +010077 .setStatus("Success")
78 ],
79 components: [
PineaFan5bea7e12023-01-05 21:20:04 +000080 new ActionRowBuilder<ButtonBuilder>().addComponents([
TheCodedProf21c08592022-09-13 14:14:43 -040081 new Discord.ButtonBuilder().setCustomId("1m").setLabel("1 Minute").setStyle(ButtonStyle.Secondary),
82 new Discord.ButtonBuilder().setCustomId("10m").setLabel("10 Minutes").setStyle(ButtonStyle.Secondary),
83 new Discord.ButtonBuilder().setCustomId("30m").setLabel("30 Minutes").setStyle(ButtonStyle.Secondary),
84 new Discord.ButtonBuilder().setCustomId("1h").setLabel("1 Hour").setStyle(ButtonStyle.Secondary)
Skyler Grey75ea9172022-08-06 10:22:23 +010085 ]),
PineaFan5bea7e12023-01-05 21:20:04 +000086 new ActionRowBuilder<ButtonBuilder>().addComponents([
TheCodedProf21c08592022-09-13 14:14:43 -040087 new Discord.ButtonBuilder().setCustomId("6h").setLabel("6 Hours").setStyle(ButtonStyle.Secondary),
88 new Discord.ButtonBuilder().setCustomId("12h").setLabel("12 Hours").setStyle(ButtonStyle.Secondary),
89 new Discord.ButtonBuilder().setCustomId("1d").setLabel("1 Day").setStyle(ButtonStyle.Secondary),
90 new Discord.ButtonBuilder().setCustomId("1w").setLabel("1 Week").setStyle(ButtonStyle.Secondary)
Skyler Grey75ea9172022-08-06 10:22:23 +010091 ]),
PineaFan5bea7e12023-01-05 21:20:04 +000092 new ActionRowBuilder<ButtonBuilder>().addComponents([
TheCodedProf21c08592022-09-13 14:14:43 -040093 new Discord.ButtonBuilder()
Skyler Grey75ea9172022-08-06 10:22:23 +010094 .setCustomId("cancel")
95 .setLabel("Cancel")
TheCodedProf21c08592022-09-13 14:14:43 -040096 .setStyle(ButtonStyle.Danger)
Skyler Grey75ea9172022-08-06 10:22:23 +010097 .setEmoji(getEmojiByName("CONTROL.CROSS", "id"))
98 ])
99 ],
100 ephemeral: true,
101 fetchReply: true
PineaFan5bea7e12023-01-05 21:20:04 +0000102 });
pineafan8b4b17f2022-02-27 20:42:52 +0000103 let component;
104 try {
Skyler Grey75ea9172022-08-06 10:22:23 +0100105 component = await m.awaitMessageComponent({
106 filter: (m) => m.user.id === interaction.user.id,
107 time: 300000
108 });
109 } catch {
110 return;
111 }
pineafan8b4b17f2022-02-27 20:42:52 +0000112 component.deferUpdate();
Skyler Grey75ea9172022-08-06 10:22:23 +0100113 if (component.customId === "cancel")
114 return interaction.editReply({
115 embeds: [
116 new EmojiEmbed()
117 .setEmoji("PUNISH.MUTE.RED")
118 .setTitle("Mute")
119 .setDescription("Mute cancelled")
120 .setStatus("Danger")
121 ]
122 });
pineafan8b4b17f2022-02-27 20:42:52 +0000123 switch (component.customId) {
Skyler Grey75ea9172022-08-06 10:22:23 +0100124 case "1m": {
125 muteTime = 60;
126 break;
127 }
128 case "10m": {
129 muteTime = 60 * 10;
130 break;
131 }
132 case "30m": {
133 muteTime = 60 * 30;
134 break;
135 }
136 case "1h": {
137 muteTime = 60 * 60;
138 break;
139 }
140 case "6h": {
141 muteTime = 60 * 60 * 6;
142 break;
143 }
144 case "12h": {
145 muteTime = 60 * 60 * 12;
146 break;
147 }
148 case "1d": {
149 muteTime = 60 * 60 * 24;
150 break;
151 }
152 case "1w": {
153 muteTime = 60 * 60 * 24 * 7;
154 break;
155 }
pineafan8b4b17f2022-02-27 20:42:52 +0000156 }
157 } else {
Skyler Grey75ea9172022-08-06 10:22:23 +0100158 await interaction.reply({
159 embeds: LoadingEmbed,
160 ephemeral: true,
161 fetchReply: true
162 });
pineafan8b4b17f2022-02-27 20:42:52 +0000163 }
pineafan5d1908e2022-02-28 21:34:47 +0000164 // TODO:[Modals] Replace this with a modal
pineafan62ce1922022-08-25 20:34:45 +0100165 let reason: string | null = null;
pineafan02ba0232022-07-24 22:16:15 +0100166 let notify = true;
167 let createAppealTicket = false;
pineafan73a7c4a2022-07-24 10:38:04 +0100168 let confirmation;
Skyler Greyad002172022-08-16 18:48:26 +0100169 let timedOut = false;
170 let success = false;
pineafan62ce1922022-08-25 20:34:45 +0100171 do {
pineafan73a7c4a2022-07-24 10:38:04 +0100172 confirmation = await new confirmationMessage(interaction)
173 .setEmoji("PUNISH.MUTE.RED")
174 .setTitle("Mute")
Skyler Grey75ea9172022-08-06 10:22:23 +0100175 .setDescription(
176 keyValueList({
PineaFan5bea7e12023-01-05 21:20:04 +0000177 user: renderUser(member.user),
Skyler Grey75ea9172022-08-06 10:22:23 +0100178 time: `${humanizeDuration(muteTime * 1000, {
179 round: true
180 })}`,
pineafan62ce1922022-08-25 20:34:45 +0100181 reason: reason ? "\n> " + (reason).replaceAll("\n", "\n> ") : "*No reason provided*"
Skyler Grey75ea9172022-08-06 10:22:23 +0100182 }) +
183 "The user will be " +
184 serverSettingsDescription +
PineaFan5bea7e12023-01-05 21:20:04 +0000185 "\n\n" +
186 `Are you sure you want to mute <@!${member.id}>?`
Skyler Grey75ea9172022-08-06 10:22:23 +0100187 )
pineafan73a7c4a2022-07-24 10:38:04 +0100188 .setColor("Danger")
189 .addCustomBoolean(
Skyler Grey75ea9172022-08-06 10:22:23 +0100190 "appeal",
191 "Create appeal ticket",
PineaFana00db1b2023-01-02 15:32:54 +0000192 !(await areTicketsEnabled(interaction.guild.id)),
PineaFan5bea7e12023-01-05 21:20:04 +0000193 async () => await create(interaction.guild!, interaction.options.getUser("user")!, interaction.user, reason),
Skyler Grey75ea9172022-08-06 10:22:23 +0100194 "An appeal ticket will be created when Confirm is clicked",
PineaFana34d04b2023-01-03 22:05:42 +0000195 null,
Skyler Grey75ea9172022-08-06 10:22:23 +0100196 "CONTROL.TICKET",
197 createAppealTicket
198 )
199 .addCustomBoolean(
200 "notify",
201 "Notify user",
202 false,
Skyler Grey75ea9172022-08-06 10:22:23 +0100203 null,
PineaFan5bea7e12023-01-05 21:20:04 +0000204 "The user will be sent a DM",
PineaFana34d04b2023-01-03 22:05:42 +0000205 null,
Skyler Grey75ea9172022-08-06 10:22:23 +0100206 "ICONS.NOTIFY." + (notify ? "ON" : "OFF"),
207 notify
208 )
pineafan73a7c4a2022-07-24 10:38:04 +0100209 .addReasonButton(reason ?? "")
PineaFan1dee28f2023-01-16 22:09:07 +0000210 .setFailedMessage("No changes were made", "Success", "PUNISH.MUTE.GREEN")
pineafan63fc5e22022-08-04 22:04:10 +0100211 .send(true);
212 reason = reason ?? "";
Skyler Greyad002172022-08-16 18:48:26 +0100213 if (confirmation.cancelled) timedOut = true;
PineaFan5bea7e12023-01-05 21:20:04 +0000214 else if (confirmation.success) success = true;
215 else if (confirmation.newReason) reason = confirmation.newReason;
216 else if (confirmation.components) {
pineafan62ce1922022-08-25 20:34:45 +0100217 notify = confirmation.components["notify"]!.active;
218 createAppealTicket = confirmation.components["appeal"]!.active;
pineafan02ba0232022-07-24 22:16:15 +0100219 }
pineafan62ce1922022-08-25 20:34:45 +0100220 } while (!timedOut && !success)
PineaFan1dee28f2023-01-16 22:09:07 +0000221 if (timedOut || !confirmation.success) return;
PineaFan5bea7e12023-01-05 21:20:04 +0000222 const status: {timeout: boolean | null, role: boolean | null, dm: boolean | null} = {timeout: null, role: null, dm: null};
223 let dmMessage;
224 try {
225 if (notify) {
PineaFan538d3752023-01-12 21:48:23 +0000226 if (reason) { reason = reason.split("\n").map((line) => "> " + line).join("\n") }
PineaFan5bea7e12023-01-05 21:20:04 +0000227 const messageData: {
228 embeds: EmojiEmbed[];
229 components: ActionRowBuilder<ButtonBuilder>[];
230 } = {
231 embeds: [
232 new EmojiEmbed()
233 .setEmoji("PUNISH.MUTE.RED")
234 .setTitle("Muted")
235 .setDescription(
236 `You have been muted in ${interaction.guild.name}` +
PineaFan538d3752023-01-12 21:48:23 +0000237 (reason ? ` for:\n${reason}` : ".\n*No reason was provided*") + "\n\n" +
PineaFan5bea7e12023-01-05 21:20:04 +0000238 `You will be unmuted at: <t:${Math.round(new Date().getTime() / 1000) + muteTime}:D> at ` +
239 `<t:${Math.round(new Date().getTime() / 1000) + muteTime}:T> (<t:${Math.round(new Date().getTime() / 1000) + muteTime
240 }:R>)` + "\n\n" +
241 (createAppealTicket
242 ? `You can appeal this in the ticket created in <#${confirmation.components!["appeal"]!.response}>`
243 : "")
244 )
245 .setStatus("Danger")
246 ],
247 components: []
248 }
249 if (config.moderation.mute.text && config.moderation.mute.link) {
250 messageData.embeds[0]!.setFooter(LinkWarningFooter);
251 messageData.components.push(new ActionRowBuilder<Discord.ButtonBuilder>()
252 .addComponents(new ButtonBuilder()
253 .setStyle(ButtonStyle.Link)
254 .setLabel(config.moderation.mute.text)
255 .setURL(config.moderation.mute.link)
256 )
257 )
258 };
259 dmMessage = await member.send(messageData);
260 status.dm = true;
261 }
262 } catch {
263 status.dm = false;
264 }
265 try {
266 if (config.moderation.mute.timeout) {
267 await member.timeout(muteTime * 1000, reason || "*No reason provided*");
268 if (config.moderation.mute.role !== null) {
269 await member.roles.add(config.moderation.mute.role);
270 await client.database.eventScheduler.schedule("naturalUnmute", (new Date().getTime() + muteTime * 1000).toString(), {
271 guild: interaction.guild.id,
272 user: member.id,
273 expires: new Date().getTime() + muteTime * 1000
274 });
275 }
276 } else {
277 status.timeout = true;
278 }
279 } catch {
280 status.timeout = false;
281 }
282 try {
283 if (config.moderation.mute.role !== null) {
284 await member.roles.add(config.moderation.mute.role);
285 await client.database.eventScheduler.schedule("unmuteRole", (new Date().getTime() + muteTime * 1000).toString(), {
286 guild: interaction.guild.id,
287 user: member.id,
288 role: config.moderation.mute.role
289 });
290 } else {
291 status.role = true;
292 }
293 } catch {
294 status.role = false;
295 }
296 const countTrue = (items: (boolean | null)[]) => items.filter(item => item === true).length;
297 const requiredPunishments = countTrue([config.moderation.mute.timeout, config.moderation.mute.role !== null]);
298 const actualPunishments = countTrue([status.timeout, status.role]);
299
300 await client.database.history.create("mute", interaction.guild.id, member.user, interaction.user, reason);
301 if (requiredPunishments !== actualPunishments) {
302 const messages = [];
303 if (config.moderation.mute.timeout) messages.push(`The member was ${status.timeout ? "" : "not "}timed out`);
304 if (config.moderation.mute.role !== null) messages.push(`The member was ${status.role ? "" : "not "}given the mute role`);
305 messages.push(`The member was not sent a DM`);
306 if (dmMessage && actualPunishments === 0) await dmMessage.delete();
307 await interaction.editReply({
308 embeds: [
309 new EmojiEmbed()
310 .setEmoji("PUNISH.MUTE." + (actualPunishments > 0 ? "YELLOW" : "RED"))
311 .setTitle("Mute")
312 .setDescription(
313 "Mute " + (actualPunishments > 0 ? "partially" : "failed") + ":\n" +
314 messages.map(message => `> ${message}`).join("\n")
315 )
316 .setStatus(actualPunishments > 0 ? "Warning" : "Danger")
317 ],
318 components: []
319 });
320 }
321 const data = {
322 meta: {
323 type: "memberMute",
324 displayName: "Member Muted",
325 calculateType: "guildMemberPunish",
326 color: NucleusColors.yellow,
327 emoji: "PUNISH.WARN.YELLOW",
328 timestamp: new Date().getTime()
329 },
330 list: {
331 memberId: entry(member.user.id, `\`${member.user.id}\``),
332 name: entry(member.user.id, renderUser(member.user)),
333 mutedUntil: entry(
334 (new Date().getTime() + muteTime * 1000).toString(),
335 renderDelta(new Date().getTime() + muteTime * 1000)
336 ),
337 muted: entry(new Date().getTime.toString(), renderDelta(new Date().getTime() - 1000)),
338 mutedBy: entry(interaction.member!.user.id, renderUser(interaction.member!.user as Discord.User)),
339 reason: entry(reason, reason ? reason : "*No reason provided*")
340 },
341 hidden: {
342 guild: interaction.guild.id
343 }
344 };
345 log(data);
346 const failed = !status.dm && notify;
347 await interaction.editReply({
348 embeds: [
349 new EmojiEmbed()
350 .setEmoji(`PUNISH.MUTE.${failed ? "YELLOW" : "GREEN"}`)
351 .setTitle("Mute")
352 .setDescription(
353 "The member was muted" + (failed ? ", but could not be notified" : "") +
354 (createAppealTicket
355 ? ` and an appeal ticket was opened in <#${confirmation.components!["appeal"]!.response}>`
356 : "")
357 )
358 .setStatus(failed ? "Warning" : "Success")
359 ],
360 components: []
361 });
pineafan63fc5e22022-08-04 22:04:10 +0100362};
pineafan8b4b17f2022-02-27 20:42:52 +0000363
pineafanbd02b4a2022-08-05 22:01:38 +0100364const check = (interaction: CommandInteraction) => {
PineaFana00db1b2023-01-02 15:32:54 +0000365 if (!interaction.guild) return;
Skyler Grey75ea9172022-08-06 10:22:23 +0100366 const member = interaction.member as GuildMember;
PineaFan5bea7e12023-01-05 21:20:04 +0000367 const me = interaction.guild.members.me!;
Skyler Grey75ea9172022-08-06 10:22:23 +0100368 const apply = interaction.options.getMember("user") as GuildMember;
pineafan62ce1922022-08-25 20:34:45 +0100369 const memberPos = member.roles.cache.size > 1 ? member.roles.highest.position : 0;
370 const mePos = me.roles.cache.size > 1 ? me.roles.highest.position : 0;
371 const applyPos = apply.roles.cache.size > 1 ? apply.roles.highest.position : 0;
pineafanc1c18792022-08-03 21:41:36 +0100372 // Do not allow muting the owner
PineaFana00db1b2023-01-02 15:32:54 +0000373 if (member.id === interaction.guild.ownerId) throw new Error("You cannot mute the owner of the server");
pineafan8b4b17f2022-02-27 20:42:52 +0000374 // Check if Nucleus can mute the member
pineafan3a02ea32022-08-11 21:35:04 +0100375 if (!(mePos > applyPos)) throw new Error("I do not have a role higher than that member");
pineafan8b4b17f2022-02-27 20:42:52 +0000376 // Check if Nucleus has permission to mute
PineaFan5bea7e12023-01-05 21:20:04 +0000377 if (!me.permissions.has("ModerateMembers")) throw new Error("I do not have the *Moderate Members* permission");
pineafan8b4b17f2022-02-27 20:42:52 +0000378 // Do not allow muting Nucleus
pineafan3a02ea32022-08-11 21:35:04 +0100379 if (member.id === me.id) throw new Error("I cannot mute myself");
pineafan8b4b17f2022-02-27 20:42:52 +0000380 // Allow the owner to mute anyone
PineaFana00db1b2023-01-02 15:32:54 +0000381 if (member.id === interaction.guild.ownerId) return true;
pineafan8b4b17f2022-02-27 20:42:52 +0000382 // Check if the user has moderate_members permission
PineaFan5bea7e12023-01-05 21:20:04 +0000383 if (!member.permissions.has("ModerateMembers"))
pineafan3a02ea32022-08-11 21:35:04 +0100384 throw new Error("You do not have the *Moderate Members* permission");
pineafan8b4b17f2022-02-27 20:42:52 +0000385 // Check if the user is below on the role list
pineafan3a02ea32022-08-11 21:35:04 +0100386 if (!(memberPos > applyPos)) throw new Error("You do not have a role higher than that member");
pineafan8b4b17f2022-02-27 20:42:52 +0000387 // Allow mute
pineafan63fc5e22022-08-04 22:04:10 +0100388 return true;
389};
pineafan8b4b17f2022-02-27 20:42:52 +0000390
Skyler Grey75ea9172022-08-06 10:22:23 +0100391export { command, callback, check };