diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f22de63 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,62 @@ +{ + "cSpell.ignoreWords": [ + "BLOCKCROSS", + "CLICKSDEVELOPER", + "Captchas", + "Categorises", + "MODLEVEL", + "NUCLEUSDEVELOPER", + "Texttools", + "Tfme", + "bitfield", + "categorised", + "clcks", + "closeticket", + "colour", + "createtestbutton", + "createticket", + "hcaptcha", + "maxtickets", + "noopener", + "noreferrer", + "pypi", + "roleall", + "rolemenu", + "setlog", + "setprefix", + "setverify", + "stafflog", + "supportping", + "tesseract", + "unscan", + "verifybutton", + "viewas" + ], + "cSpell.words": [ + "automations", + "categorise", + "CFSERVICE", + "CLEARHISTORY", + "CLICKSFORMS", + "CMPING", + "discordjs", + "ffffe", + "ICONCHANGE", + "jshaiku", + "MODERATIONUPDATE", + "slowmode", + "SOFTBAN", + "softbanned", + "softbanning", + "TOOBIG", + "TOOSMALL", + "TOPICUPDATE", + "Unban", + "Unbans", + "Uncategorised", + "UNDEAFEN", + "UNMUTE", + "Unmutes", + "VOICEMUTE" + ] +} \ No newline at end of file diff --git a/clicksminutepernet b/clicksminutepernet new file mode 160000 index 0000000..3077ca8 --- /dev/null +++ b/clicksminutepernet @@ -0,0 +1 @@ +Subproject commit 3077ca8f94bae6de555bb404fd288f4059f33caa diff --git a/src/automations/createModActionTicket.ts b/src/automations/createModActionTicket.ts new file mode 100644 index 0000000..a4d549c --- /dev/null +++ b/src/automations/createModActionTicket.ts @@ -0,0 +1,82 @@ +import Discord from 'discord.js'; +import readConfig from '../utils/readConfig.js' +import generateEmojiEmbed from '../utils/generateEmojiEmbed.js' + +export async function create(guild: Discord.Guild, member: Discord.User, client) { + let config = await readConfig(guild.id); + // @ts-ignore + const { log, NucleusColors, entry, renderUser, renderChannel, renderDelta } = client.logger + let overwrites = [{ + id: member, + allow: ["VIEW_CHANNEL", "SEND_MESSAGES", "ATTACH_FILES", "ADD_REACTIONS", "READ_MESSAGE_HISTORY"], + type: "member" + }] as Discord.OverwriteResolvable[]; + if (config.tickets.supportRole != null) { + overwrites.push({ + id: guild.roles.cache.get(config.tickets.supportRole), + allow: ["VIEW_CHANNEL", "SEND_MESSAGES", "ATTACH_FILES", "ADD_REACTIONS", "READ_MESSAGE_HISTORY"], + type: "role" + }) + } + + let c; + try { + c = await guild.channels.create(member.username, { + type: "GUILD_TEXT", + topic: `${member.id} Active`, + parent: config.tickets.category, + nsfw: false, + permissionOverwrites: (overwrites as Discord.OverwriteResolvable[]), + reason: "Creating ticket" + }) + } catch (e) { + return null + } + try { + await c.send( + { + content: (`<@${member.id}>` + (config.tickets.supportRole != null ? ` • <@&${config.tickets.supportRole}>` : "")), + allowedMentions: { + users: [member.id], + roles: (config.tickets.supportRole != null ? [config.tickets.supportRole] : []) + } + } + ) + await c.send({ embeds: [new generateEmojiEmbed() + .setTitle("New Ticket") + .setDescription( + `Ticket created by a Moderator\n` + + `**Support type:** Appeal submission\n` + + `**Ticket ID:** \`${c.id}\`\n` + + `Type \`/ticket close\` to archive this ticket.`, + ) + .setStatus("Success") + .setEmoji("GUILD.TICKET.OPEN") + ]}) + let data = { + meta:{ + type: 'ticketCreate', + displayName: 'Ticket Created', + calculateType: true, + color: NucleusColors.green, + emoji: 'GUILD.TICKET.OPEN', + timestamp: new Date().getTime() + }, + list: { + ticketFor: entry(member.id, renderUser(member)), + created: entry(new Date().getTime(), renderDelta(new Date().getTime())), + ticketChannel: entry(c.id, renderChannel(c)), + }, + hidden: { + guild: guild.id + } + } + log(data, client); + } catch (e) { console.log(e); return null } + return c.id +} + +export async function areTicketsEnabled(guild: string) { + let config = await readConfig(guild); + return config.tickets.enabled; +} \ No newline at end of file diff --git a/src/automations/guide.ts b/src/automations/guide.ts new file mode 100644 index 0000000..66c9eb0 --- /dev/null +++ b/src/automations/guide.ts @@ -0,0 +1,162 @@ +import Discord, { MessageActionRow, MessageButton } from "discord.js"; +import generateEmojiEmbed from "../utils/generateEmojiEmbed.js"; +import getEmojiByName from "../utils/getEmojiByName.js"; + +export default async (guild, interaction?) => { + let c = guild.publicUpdatesChannel ? guild.publicUpdatesChannel : guild.systemChannel; + c = c ? c : guild.channels.cache.find(ch => ch.type === "GUILD_TEXT" && ch.permissionsFor(guild.roles.everyone).has("SEND_MESSAGES") && ch.permissionsFor(guild.me).has("EMBED_LINKS")); + let pages = [ + new generateEmojiEmbed() + .setTitle("Welcome to Nucleus") + .setDescription( + "Thanks for adding Nucleus to your server\n\n" + + "On the next few pages you can find instructions on getting started, and commands you may want to set up\n\n" + + "If you need support, have questions or want features, you can let us know in [Clicks](https://discord.gg/bPaNnxe)" + ) + .setEmoji("NUCLEUS.LOGO") + .setStatus("Danger"), + new generateEmojiEmbed() + .setTitle("Logging") + .setDescription( + "Nucleus can log server events and keep you informed with what content is being posted to your server.\n" + + "We have 2 different types of logs, which each can be configured to send to a channel of your choice:\n" + + "**General Logs:** These are events like kicks and channel changes etc.\n" + + "**Warning Logs:** Warnings like NSFW avatars and spam etc that may require action by a server staff member.\n\n" + + "A general log channel can be set with `/settings log channel`\n" + + "A warning log channel can be set with `/settings warnings channel`" + ) + .setEmoji("NUCLEUS.LOGO") + .setStatus("Danger"), + new generateEmojiEmbed() + .setTitle("Moderation") + .setDescription( + "Nucleus has a number of commands that can be used to moderate your server.\n" + + "These commands are all found under `/mod`, and they include:\n" + + `**${getEmojiByName("PUNISH.WARN.YELLOW")} Warn:** The user is warned (via DM) that they violated server rules.\n` + + `**${getEmojiByName("PUNISH.CLEARHISTORY")} Clear:** Some messages from a user are deleted in a channel.\n` + + `**${getEmojiByName("PUNISH.MUTE.YELLOW")} Mute:** The user is unable to send messages or join voice chats.\n` + + `**${getEmojiByName("PUNISH.MUTE.GREEN")} Unmute:** The user is able to send messages in the server.\n` + + `**${getEmojiByName("PUNISH.KICK.RED")} Kick:** The user is removed from the server.\n` + + `**${getEmojiByName("PUNISH.SOFTBAN")} Softban:** Kicks the user, deleting their messages from every channel.\n` + + `**${getEmojiByName("PUNISH.BAN.RED")} Ban:** The user is removed from the server, and they are unable to rejoin.\n` + + `**${getEmojiByName("PUNISH.BAN.GREEN")} Unban:** The user is able to rejoin the server.\n` + ) + .setEmoji("NUCLEUS.LOGO") + .setStatus("Danger"), + new generateEmojiEmbed() + .setTitle("Verify") + .setDescription( + "Nucleus has a verification system that allows users to prove they aren't bots.\n" + + "This is done by running `/verify` which sends a message only the user can see, giving them a link to a CAPTCHA to verify.\n" + + "After the user complete's the CAPTCHA, they are given a role and can use the permissions accordingly.\n" + + "You can set the role given with `/settings verify role`" + ) + .setEmoji("NUCLEUS.LOGO") + .setStatus("Danger"), + new generateEmojiEmbed() + .setTitle("Content Scanning") + .setDescription( + "Nucleus has a content scanning system that automatically scans links and images sent by users.\n" + + "Nucleus can detect, delete, and punish users for sending NSFW content, or links to scam or adult sites.\n" + + "You can set the threshold for this in `/settings automation`" + ) + .setEmoji("NUCLEUS.LOGO") + .setStatus("Danger"), + new generateEmojiEmbed() + .setTitle("Tickets") + .setDescription( + "Nucleus has a ticket system that allows users to create tickets and have a support team respond to them.\n" + + "Tickets can be created with `/ticket create` and a channel is created, pinging the user and support role.\n" + + "When the ticket is resolved, anyone can run `/ticket close` to archive it.\n" + + "Running `/ticket close` again will delete the ticket." + ) + .setEmoji("NUCLEUS.LOGO") + .setStatus("Danger") + ] + let m; + if (interaction) { + m = await interaction.reply({embeds: [ + new generateEmojiEmbed() + .setTitle("Welcome") + .setDescription(`One moment...`) + .setStatus("Danger") + .setEmoji("NUCLEUS.LOADING") + ], fetchReply: true, ephemeral: true}); + } else { + m = await c.send({embeds: [ + new generateEmojiEmbed() + .setTitle("Welcome") + .setDescription(`One moment...`) + .setStatus("Danger") + .setEmoji("NUCLEUS.LOADING") + ], fetchReply: true }); + } + let page = 0; + + let f = async (component) => { + return (component.member as Discord.GuildMember).permissions.has("MANAGE_GUILD"); + } + + while (true) { + if (interaction) { + await interaction.editReply({ + embeds: [pages[page].setFooter({text: `Page ${page + 1}/${pages.length}`})], + components: [new MessageActionRow().addComponents([ + new MessageButton().setCustomId("left").setEmoji(getEmojiByName("CONTROL.LEFT", "id")).setStyle("SECONDARY").setDisabled(page === 0), + new MessageButton().setCustomId("right").setEmoji(getEmojiByName("CONTROL.RIGHT", "id")).setStyle("SECONDARY").setDisabled(page === pages.length - 1), + new MessageButton().setCustomId("close").setEmoji(getEmojiByName("CONTROL.CROSS", "id")).setStyle("DANGER") + ])], + fetchReply: true + }); + } else { + await m.edit({ + embeds: [pages[page].setFooter({text: `Page ${page + 1}/${pages.length}`})], + components: [new MessageActionRow().addComponents([ + new MessageButton().setCustomId("left").setEmoji(getEmojiByName("CONTROL.LEFT", "id")).setStyle("SECONDARY").setDisabled(page === 0), + new MessageButton().setCustomId("right").setEmoji(getEmojiByName("CONTROL.RIGHT", "id")).setStyle("SECONDARY").setDisabled(page === pages.length - 1), + new MessageButton().setCustomId("close").setEmoji(getEmojiByName("CONTROL.CROSS", "id")).setStyle("DANGER") + ])], + fetchReply: true + }); + } + let i + try { + i = await m.awaitMessageComponent({filter: interaction ? () => { return true } : f, componentType: "BUTTON", time: 600000}); + } catch(e) { break } + i.deferUpdate() + if (i.component.customId == "left") { + if (page > 0) page--; + } else if (i.component.customId == "right") { + if (page < pages.length - 1) page++; + } else if (i.component.customId == "close") { + if (interaction) { + interaction.delete(); + } else { + m.delete(); + } + return; + } else { + await m.delete() + break; + } + } + if (interaction) { + await interaction.editReply({ + embeds: [pages[page].setFooter({text: `Page ${page + 1}/${pages.length} | Message timed out`})], + components: [new MessageActionRow().addComponents([ + new MessageButton().setCustomId("left").setEmoji(getEmojiByName("CONTROL.LEFT", "id")).setStyle("SECONDARY").setDisabled(true), + new MessageButton().setCustomId("right").setEmoji(getEmojiByName("CONTROL.RIGHT", "id")).setStyle("SECONDARY").setDisabled(true), + new MessageButton().setCustomId("close").setEmoji(getEmojiByName("CONTROL.CROSS", "id")).setStyle("DANGER").setDisabled(true) + ])] + }); + } else { + await m.edit({ + embeds: [pages[page].setFooter({text: `Page ${page + 1}/${pages.length} | Message timed out`})], + components: [new MessageActionRow().addComponents([ + new MessageButton().setCustomId("left").setEmoji(getEmojiByName("CONTROL.LEFT", "id")).setStyle("SECONDARY").setDisabled(true), + new MessageButton().setCustomId("right").setEmoji(getEmojiByName("CONTROL.RIGHT", "id")).setStyle("SECONDARY").setDisabled(true), + new MessageButton().setCustomId("close").setEmoji(getEmojiByName("CONTROL.CROSS", "id")).setStyle("DANGER").setDisabled(true) + ])] + }); + } +} diff --git a/src/automations/roleMenu.ts b/src/automations/roleMenu.ts new file mode 100644 index 0000000..2412c70 --- /dev/null +++ b/src/automations/roleMenu.ts @@ -0,0 +1,148 @@ +import { Message, MessageButton } from "discord.js"; +import readConfig from '../utils/readConfig.js' +import generateEmojiEmbed from '../utils/generateEmojiEmbed.js' +import { MessageActionRow, MessageSelectMenu } from 'discord.js'; +import getEmojiByName from "../utils/getEmojiByName.js"; + +export async function callback(interaction) { + let config = await readConfig(interaction.guild.id); + if (!config.roleMenu.enabled) await interaction.reply({embeds: [new generateEmojiEmbed() + .setTitle("Roles") + .setDescription("Self roles are currently disabled. Please contact a staff member or try again later.") + .setStatus("Danger") + .setEmoji("CONTROL.BLOCKCROSS") + ], ephemeral: true}) + if (config.roleMenu.options.length === 0) await interaction.reply({embeds: [new generateEmojiEmbed() + .setTitle("Roles") + .setDescription("There are no roles available. Please contact a staff member or try again later.") + .setStatus("Danger") + .setEmoji("CONTROL.BLOCKCROSS") + ], ephemeral: true}) + await interaction.reply({embeds: [new generateEmojiEmbed() + .setTitle("Roles") + .setDescription("Loading...") + .setStatus("Success") + .setEmoji("NUCLEUS.LOADING") + ], ephemeral: true}) + let m; + if (config.roleMenu.allowWebUI) { + let code = "" + let length = 5 + let itt = 0 + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + while (true) { + itt += 1 + code = "" + for (let i = 0; i < length; i++) { code += chars.charAt(Math.floor(Math.random() * chars.length)); } + if (code in interaction.client.roleMenu) continue; + if (itt > 1000) { + itt = 0 + length += 1 + continue; + } + break; + } + interaction.client.roleMenu[code] = { + guild: interaction.guild.id, + guildIcon: interaction.guild.iconURL({format: "png"}), + user: interaction.member.user.id, + interaction: interaction + }; + m = await interaction.editReply({ + embeds: [new generateEmojiEmbed() + .setTitle("Roles") + .setDescription("Select how to choose your roles") + .setStatus("Success") + .setEmoji("GUILD.GREEN") + ], components: [new MessageActionRow().addComponents([ + new MessageButton() + .setLabel("Online") + .setStyle("LINK") + .setURL(`https://clicksminuteper.net/nuclues/rolemenu?code=${code}`), + new MessageButton() + .setLabel("Manual") + .setStyle("PRIMARY") + .setCustomId("manual") + ])] + }) + } + let component; + try { component = await (m as Message).awaitMessageComponent({time: 2.5 * 60 * 1000}); + } catch (e) { return } + component.deferUpdate() + let rolesToAdd = [] + for (let i = 0; i < config.roleMenu.options.length; i++) { + let object = config.roleMenu.options[i]; + let m = await interaction.editReply({ + embeds: [ + new generateEmojiEmbed() + .setTitle("Roles") + .setEmoji("GUILD.GREEN") + .setDescription(`**${object.name}**` + (object.description ? `\n${object.description}` : ``) + + `\n\nSelect ${object.min}` + (object.min != object.max ? ` to ${object.max}` : ``) + ` role${object.max == 1 ? '' : 's'} to add.`) + .setStatus("Success") + .setFooter({text: `Step ${i + 1}/${config.roleMenu.options.length}`}) + ], + components: [ + new MessageActionRow().addComponents([ + new MessageButton() + .setLabel("Cancel") + .setStyle("DANGER") + .setCustomId("cancel") + .setEmoji(getEmojiByName("CONTROL.CROSS", "id")) + ].concat(object.min == 0 ? [ + new MessageButton() + .setLabel("Skip") + .setStyle("SECONDARY") + .setCustomId("skip") + .setEmoji(getEmojiByName("CONTROL.RIGHT", "id")) + ] : [])) + ].concat([new MessageActionRow().addComponents([new MessageSelectMenu() + .setPlaceholder(`${object.name}`) + .setCustomId("rolemenu") + .setMinValues(object.min) + .setMaxValues(object.max) + .setOptions(object.options.map(o => { return {label: o.name, description: o.description, value: o.role} })) + ])]) + }); + let component; + try { + component = await (m as Message).awaitMessageComponent({time: 2.5 * 60 * 1000}); + } catch (e) { + return + } + component.deferUpdate() + if (component.customId == "rolemenu") { + rolesToAdd = rolesToAdd.concat(component.values) + } else if (component.customId == "cancel") { + return await interaction.editReply({embeds: [new generateEmojiEmbed() + .setTitle("Roles") + .setDescription("Cancelled. No changes were made.") + .setStatus("Danger") + .setEmoji("GUILD.RED") + ], components: []}) + } + } + let rolesToRemove = config.roleMenu.options.map(o => o.options.map(o => o.role)).flat() + let memberRoles = interaction.member.roles.cache.map(r => r.id) + rolesToRemove = rolesToRemove.filter(r => memberRoles.includes(r)).filter(r => !rolesToAdd.includes(r)) + rolesToAdd = rolesToAdd.filter(r => !memberRoles.includes(r)) + try { + await interaction.member.roles.remove(rolesToRemove) + await interaction.member.roles.add(rolesToAdd) + } catch (e) { + return await interaction.reply({embeds: [new generateEmojiEmbed() + .setTitle("Roles") + .setDescription("Something went wrong and your roles were not added. Please contact a staff member or try again later.") + .setStatus("Danger") + .setEmoji("GUILD.RED") + ]}) + } + await interaction.editReply({embeds: [new generateEmojiEmbed() + .setTitle("Roles") + .setDescription("Roles have been added. You may close this message.") + .setStatus("Success") + .setEmoji("GUILD.GREEN") + ], components: []}) + return +} diff --git a/src/automations/tickets/create.ts b/src/automations/tickets/create.ts new file mode 100644 index 0000000..1fd743b --- /dev/null +++ b/src/automations/tickets/create.ts @@ -0,0 +1,202 @@ +import Discord, { MessageActionRow, MessageButton } from "discord.js"; +import { tickets, toHexArray } from "../../utils/calculate.js"; +import readConfig from "../../utils/readConfig.js"; +import generateEmojiEmbed from "../../utils/generateEmojiEmbed.js"; +import getEmojiByName from "../../utils/getEmojiByName.js"; + +function capitalize(s: string) { + s = s.replace(/([A-Z])/g, ' $1'); + return s.length < 3 ? s.toUpperCase() : s[0].toUpperCase() + s.slice(1).toLowerCase(); +} + +export default async function (interaction) { + // @ts-ignore + const { log, NucleusColors, entry, renderUser, renderChannel, renderDelta } = interaction.client.logger + + let config = await readConfig(interaction.guild.id); + if (!config.tickets.enabled || !config.tickets.category) { + return await interaction.reply({embeds: [new generateEmojiEmbed() + .setTitle("Tickets are disabled") + .setDescription("Please enable tickets in the configuration to use this command.") + .setStatus("Danger") + .setEmoji("CONTROL.BLOCKCROSS") + ], ephemeral: true}); + } + let category = interaction.guild.channels.cache.get(config.tickets.category) as Discord.CategoryChannel; + let count = 0; + category.children.forEach(element => { + if (!(element.type == "GUILD_TEXT")) return; + if ((element as Discord.TextChannel).topic.includes(`${interaction.member.user.id}`)) { + if ((element as Discord.TextChannel).topic.endsWith("Active")) { + count++; + } + } + }); + if (count >= config.tickets.maxTickets) { + return await interaction.reply({embeds: [new generateEmojiEmbed() + .setTitle("Create Ticket") + .setDescription(`You have reached the maximum amount of tickets (${config.tickets.maxTickets}). Please close one of your active tickets before creating a new one.`) + .setStatus("Danger") + .setEmoji("CONTROL.BLOCKCROSS") + ], ephemeral: true}); + } + let ticketTypes; + let custom = false + if (config.tickets.customTypes) { ticketTypes = config.tickets.customTypes; custom = true } + else if (config.tickets.types) ticketTypes = toHexArray(config.tickets.types, tickets); + else ticketTypes = []; + let chosenType; + let splitFormattedTicketTypes = []; + if (ticketTypes.length > 0) { + let formattedTicketTypes = []; + formattedTicketTypes = ticketTypes.map(type => { + if (custom) { + return new MessageButton() + .setLabel(type) + .setStyle("PRIMARY") + .setCustomId(type) + } else { + return new MessageButton() + .setLabel(capitalize(type)) + .setStyle("PRIMARY") + .setCustomId(type) + .setEmoji(getEmojiByName(("TICKETS." + type.toString().toUpperCase()), "id")); + } + }); + for (let i = 0; i < formattedTicketTypes.length; i += 4) { + splitFormattedTicketTypes.push(new MessageActionRow().addComponents(formattedTicketTypes.slice(i, i + 4))); + } + let m = await interaction.reply({embeds: [new generateEmojiEmbed() + .setTitle("Create Ticket") + .setDescription("Select a ticket type") + .setStatus("Success") + .setEmoji("GUILD.TICKET.OPEN") + ], ephemeral: true, fetchReply: true, components: splitFormattedTicketTypes}); + let component; + try { + component = await (m as Discord.Message).awaitMessageComponent({time: 2.5 * 60 * 1000}); + } catch (e) { + return; + } + chosenType = component.customId; + splitFormattedTicketTypes = []; + formattedTicketTypes = []; + formattedTicketTypes = ticketTypes.map(type => { + if (custom) { + return new MessageButton() + .setLabel(type) + .setStyle(chosenType == type ? "SUCCESS" : "SECONDARY") + .setCustomId(type) + .setDisabled(true) + } else { + return new MessageButton() + .setLabel(capitalize(type)) + .setStyle(chosenType == type ? "SUCCESS" : "SECONDARY") + .setCustomId(type) + .setEmoji(getEmojiByName(("TICKETS." + type.toString().toUpperCase()), "id")) + .setDisabled(true) + } + }); + for (let i = 0; i < formattedTicketTypes.length; i += 4) { + splitFormattedTicketTypes.push(new MessageActionRow().addComponents(formattedTicketTypes.slice(i, i + 4))); + } + component.update({embeds: [new generateEmojiEmbed() + .setTitle("Create Ticket") + .setDescription("Select a ticket type") + .setStatus("Success") + .setEmoji("GUILD.TICKET.OPEN") + ], components: splitFormattedTicketTypes}); + } else { + chosenType = null + await interaction.reply({embeds: [new generateEmojiEmbed() + .setTitle("Create Ticket") + .setEmoji("GUILD.TICKET.OPEN") + ], ephemeral: true, components: splitFormattedTicketTypes}) + } + let overwrites = [{ + id: interaction.member, + allow: ["VIEW_CHANNEL", "SEND_MESSAGES", "ATTACH_FILES", "ADD_REACTIONS", "READ_MESSAGE_HISTORY"], + type: "member" + }] as Discord.OverwriteResolvable[]; + if (config.tickets.supportRole != null) { + overwrites.push({ + id: interaction.guild.roles.cache.get(config.tickets.supportRole), + allow: ["VIEW_CHANNEL", "SEND_MESSAGES", "ATTACH_FILES", "ADD_REACTIONS", "READ_MESSAGE_HISTORY"], + type: "role" + }) + } + + let c; + try { + c = await interaction.guild.channels.create(interaction.member.user.username, { + type: "GUILD_TEXT", + topic: `${interaction.member.user.id} Active`, + parent: config.tickets.category, + nsfw: false, + permissionOverwrites: (overwrites as Discord.OverwriteResolvable[]), + reason: "Creating ticket" + }) + } catch (e) { + return await interaction.editReply({embeds: [new generateEmojiEmbed() + .setTitle("Create Ticket") + .setDescription("Failed to create ticket") + .setStatus("Danger") + .setEmoji("CONTROL.BLOCKCROSS") + ]}); + } + try { + await c.send( + { + content: (`<@${interaction.member.user.id}>` + (config.tickets.supportRole != null ? ` • <@&${config.tickets.supportRole}>` : "")), + allowedMentions: { + users: [(interaction.member as Discord.GuildMember).id], + roles: (config.tickets.supportRole != null ? [config.tickets.supportRole] : []) + } + } + ) + let content = interaction.options ? interaction.options.getString("message") || "" : ""; + if (content) content = `**Message:**\n> ${content}\n`; + let emoji = custom ? "" : getEmojiByName("TICKETS." + chosenType.toUpperCase()); + await c.send({ embeds: [new generateEmojiEmbed() + .setTitle("New Ticket") + .setDescription( + `Ticket created by <@${interaction.member.user.id}>\n` + + `**Support type:** ${chosenType != null ? (emoji) + " " + capitalize(chosenType) : "General"}\n` + + `**Ticket ID:** \`${c.id}\`\n${content}\n` + + `Type \`/ticket close\` to archive this ticket.`, + ) + .setStatus("Success") + .setEmoji("GUILD.TICKET.OPEN") + ], components: [new MessageActionRow().addComponents([new MessageButton() + .setLabel("Close") + .setStyle("DANGER") + .setCustomId("closeticket") + .setEmoji(getEmojiByName("CONTROL.CROSS", "id")) + ])]}) + let data = { + meta:{ + type: 'ticketCreate', + displayName: 'Ticket Created', + calculateType: true, + color: NucleusColors.green, + emoji: 'GUILD.TICKET.OPEN', + timestamp: new Date().getTime() + }, + list: { + ticketFor: entry(interaction.member.user.id, renderUser(interaction.member.user)), + created: entry(new Date().getTime(), renderDelta(new Date().getTime())), + ticketChannel: entry(c.id, renderChannel(c)), + }, + hidden: { + guild: interaction.guild.id + } + } + log(data, interaction.client); + } catch (e) { console.log(e)} + await interaction.editReply({embeds: [new generateEmojiEmbed() + .setTitle("Create Ticket") + .setDescription(`Ticket created. You can view it here: <#${c.id}>`) + .setStatus("Success") + .setEmoji("GUILD.TICKET.OPEN") + ], components: splitFormattedTicketTypes}); +} \ No newline at end of file diff --git a/src/automations/tickets/delete.ts b/src/automations/tickets/delete.ts new file mode 100644 index 0000000..8feeb4b --- /dev/null +++ b/src/automations/tickets/delete.ts @@ -0,0 +1,137 @@ +import Discord, { MessageButton, MessageActionRow } from "discord.js"; +import generateEmojiEmbed from "../../utils/generateEmojiEmbed.js"; +import getEmojiByName from "../../utils/getEmojiByName.js"; +import readConfig from "../../utils/readConfig.js"; + +export default async function (interaction) { + // @ts-ignore + const { log, NucleusColors, entry, renderUser, renderChannel, renderDelta } = interaction.client.logger + + let config = await readConfig(interaction.guild.id); + let channel = (interaction.channel as Discord.TextChannel) + if (config.tickets.category != channel.parent.id) { + return interaction.reply({embeds: [new generateEmojiEmbed() + .setTitle("Close Ticket") + .setDescription("This ticket is not in your tickets category, so cannot be deleted.") + .setStatus("Danger") + .setEmoji("CONTROL.BLOCKCROSS") + ], ephemeral: true}); + } + let status = channel.topic.split(" ")[1]; + if (status == "Archived") { + interaction.reply({embeds: [new generateEmojiEmbed() + .setTitle("Close Ticket") + .setDescription("This ticket will be deleted in 3 seconds.") + .setStatus("Danger") + .setEmoji("GUILD.TICKET.CLOSE") + ]}); + setTimeout(async () => { + let data = { + meta:{ + type: 'ticketClosed', + displayName: 'Ticket Closed', + calculateType: true, + color: NucleusColors.red, + emoji: 'GUILD.TICKET.CLOSE', + timestamp: new Date().getTime() + }, + list: { + ticketFor: entry(channel.topic.split(" ")[0], renderUser((await interaction.guild.members.fetch(channel.topic.split(" ")[0])).user)), + closedBy: entry(interaction.member.user.id, renderUser(interaction.member.user)), + closed: entry(new Date().getTime(), renderDelta(new Date().getTime())) + }, + hidden: { + guild: interaction.guild.id + } + } + log(data, interaction.client); + interaction.channel.delete(); + }, 3000); + return; + } else if (status == "Active") { + interaction.reply({embeds: [new generateEmojiEmbed() + .setTitle("Close Ticket") + .setDescription("This ticket will be archived in 3 seconds.") + .setStatus("Warning") + .setEmoji("GUILD.TICKET.ARCHIVED") + ]}); + setTimeout(async () =>{ + channel.permissionsFor(await interaction.guild.members.fetch(channel.topic.split(" ")[0])).remove("VIEW_CHANNEL"); + channel.setTopic(`${channel.topic.split(" ")[0]} Archived`); + let data = { + meta:{ + type: 'ticketArchive', + displayName: 'Ticket Archived', + calculateType: true, + color: NucleusColors.yellow, + emoji: 'GUILD.TICKET.ARCHIVED', + timestamp: new Date().getTime() + }, + list: { + ticketFor: entry(channel.topic.split(" ")[0], renderUser((await interaction.guild.members.fetch(channel.topic.split(" ")[0])).user)), + archivedBy: entry(interaction.member.user.id, renderUser(interaction.member.user)), + archived: entry(new Date().getTime(), renderDelta(new Date().getTime())), + ticketChannel: entry(channel.id, renderChannel(channel)), + }, + hidden: { + guild: interaction.guild.id + } + } + log(data, interaction.client); + await interaction.editReply({embeds: [new generateEmojiEmbed() + .setTitle("Close Ticket") + .setDescription("This ticket has been archived.\nType `/ticket close` to delete it.") + .setStatus("Warning") + .setEmoji("GUILD.TICKET.ARCHIVED") // TODO:[Premium] Add a transcript option ||\----/|| <- the bridge we will cross when we come to it + ], components: [new MessageActionRow().addComponents([new MessageButton() + .setLabel("Close") + .setStyle("DANGER") + .setCustomId("closeticket") + .setEmoji(getEmojiByName("CONTROL.CROSS", "id")) + ])]}); + }, 3000); + return; + } +} + +async function purgeByUser(member, guild) { + let config = await readConfig(guild.id); + if (!config.tickets.category) return; + let tickets = guild.channels.cache.get(config.tickets.category); + let ticketChannels = tickets.children; + let deleted = 0 + ticketChannels.forEach(element => { + if (element.type != "GUILD_TEXT") return; + if (element.topic.split(" ")[0] == member) { + element.delete(); + deleted++ + } + }); + if (deleted) { + try { + const { log, NucleusColors, entry, renderUser, renderDelta } = member.client.logger + let data = { + meta:{ + type: 'ticketPurge', + displayName: 'Tickets Purged', + calculateType: true, + color: NucleusColors.red, + emoji: 'GUILD.TICKET.DELETE', + timestamp: new Date().getTime() + }, + list: { + ticketFor: entry(member, renderUser(member)), + deletedBy: entry(null, "Member left server"), + deleted: entry(new Date().getTime(), renderDelta(new Date().getTime())), + ticketsDeleted: deleted, + }, + hidden: { + guild: guild.id + } + } + log(data, member.client); + } catch {} + } +} + +export { purgeByUser } \ No newline at end of file diff --git a/src/automations/verify.ts b/src/automations/verify.ts new file mode 100644 index 0000000..80087a4 --- /dev/null +++ b/src/automations/verify.ts @@ -0,0 +1,129 @@ +import Discord, { CommandInteraction, GuildMember } from "discord.js"; +import generateEmojiEmbed from "../utils/generateEmojiEmbed.js"; +import readConfig from "../utils/readConfig.js"; +import fetch from "node-fetch"; +import { TestString, NSFWCheck } from "../automations/unscan.js"; + +export default async function(interaction) { + // @ts-ignore + let verify = interaction.client.verify + await interaction.reply({embeds: [new generateEmojiEmbed() + .setTitle("Loading") + .setStatus("Danger") + .setEmoji("NUCLEUS.LOADING") + ], ephemeral: true, fetchReply: true}); + let config = await readConfig(interaction.guild.id); + if ((interaction.member as GuildMember).roles.cache.has(config.verify.role)) { + return await interaction.editReply({embeds: [new generateEmojiEmbed() + .setTitle("Verify") + .setDescription(`You already have the <@&${config.verify.role}> role`) + .setStatus("Danger") + .setEmoji("CONTROL.BLOCKCROSS") + ]}); + } + await interaction.editReply({embeds: [new generateEmojiEmbed() + .setTitle("Verify") + .setDescription(`Checking our servers are up`) + .setStatus("Warning") + .setEmoji("NUCLEUS.LOADING") + ]}); + try { + let status = await fetch(`https://clicksminuteper.net`).then(res => res.status); + if (status != 200) { + return await interaction.editReply({embeds: [new generateEmojiEmbed() + .setTitle("Verify") + .setDescription(`Our servers appear to be down, please try again later`) + .setStatus("Danger") + .setEmoji("CONTROL.BLOCKCROSS") + ]}); + } + } catch { + return await interaction.editReply({embeds: [new generateEmojiEmbed() + .setTitle("Verify") + .setDescription(`Our servers appear to be down, please try again later`) + .setStatus("Danger") + .setEmoji("CONTROL.BLOCKCROSS") + ], components: [new Discord.MessageActionRow().addComponents([ + new Discord.MessageButton() + .setLabel("Open webpage") + .setStyle("LINK") + .setURL("https://clicksminuteper.net/"), + new Discord.MessageButton() + .setLabel("Support") + .setStyle("LINK") + .setURL("https://discord.gg/bPaNnxe") + ])]}); + } + if (config.filters.images.NSFW) { + await interaction.editReply({embeds: [new generateEmojiEmbed() + .setTitle("Verify") + .setDescription(`Checking your avatar is safe for work`) + .setStatus("Warning") + .setEmoji("NUCLEUS.LOADING") + ]}); + if (await NSFWCheck((interaction.member as GuildMember).user.avatarURL({format: "png"}))) { + return await interaction.editReply({embeds: [new generateEmojiEmbed() + .setTitle("Verify") + .setDescription(`Your avatar was detected as NSFW, which we do not allow in this server.\nPlease contact one of our staff members if you believe this is a mistake`) + .setStatus("Danger") + .setEmoji("CONTROL.BLOCKCROSS") + ]}); + } + } + if (config.filters.wordFilter) { + await interaction.editReply({embeds: [new generateEmojiEmbed() + .setTitle("Verify") + .setDescription(`Checking your name is allowed`) + .setStatus("Warning") + .setEmoji("NUCLEUS.LOADING") + ]}); + if (TestString((interaction.member as Discord.GuildMember).displayName, config.filters.wordFilter.words.loose, config.filters.wordFilter.words.strict) != "none") { + return await interaction.editReply({embeds: [new generateEmojiEmbed() + .setTitle("Verify") + .setDescription(`Your name contained a word we do not allow in this server.\nPlease contact one of our staff members if you believe this is a mistake`) + .setStatus("Danger") + .setEmoji("CONTROL.BLOCKCROSS") + ]}); + } + } + await interaction.editReply({embeds: [new generateEmojiEmbed() + .setTitle("Verify") + .setDescription(`One moment...`) + .setStatus("Warning") + .setEmoji("NUCLEUS.LOADING") + ]}); + let code = "" + let length = 5 + let itt = 0 + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + while (true) { + itt += 1 + code = "" + for (let i = 0; i < length; i++) { code += chars.charAt(Math.floor(Math.random() * chars.length)); } + if (code in verify) continue; + if (itt > 1000) { + itt = 0 + length += 1 + continue + } + break; + } + verify[code] = { + uID: interaction.member.user.id, + gID: interaction.guild.id, + rName: (await interaction.guild.roles.fetch(config.verify.role)).name, + mCount: interaction.guild.memberCount, + gName: interaction.guild.name, + guildIcon: interaction.guild.iconURL({format: "png"}) + } + await interaction.editReply({embeds: [new generateEmojiEmbed() + .setTitle("Verify") + .setDescription(`Looking good!\nClick the button below to get verified`) + .setStatus("Success") + .setEmoji("MEMBER.JOIN") + ], components: [new Discord.MessageActionRow().addComponents([new Discord.MessageButton() + .setLabel("Verify") + .setStyle("LINK") + .setURL(`https://clicksminuteper.net/nucleus/verify?code=${code}`) + ])]}); +} \ No newline at end of file diff --git a/src/commands/categorisationTest.ts b/src/commands/categorisationTest.ts new file mode 100644 index 0000000..2852a2b --- /dev/null +++ b/src/commands/categorisationTest.ts @@ -0,0 +1,75 @@ +import { CommandInteraction, MessageActionRow, MessageButton, MessageSelectMenu } from "discord.js"; +import { SelectMenuComponent, SelectMenuOption, SlashCommandBuilder } from "@discordjs/builders"; +import { WrappedCheck } from "jshaiku"; +import generateEmojiEmbed from "../utils/generateEmojiEmbed.js"; +import generateKeyValueList, { toCapitals } from "../utils/generateKeyValueList.js"; +import getEmojiByName from "../utils/getEmojiByName.js"; + +const command = new SlashCommandBuilder() + .setName("categorise") + .setDescription("Categorises your servers channels") + +const callback = async (interaction: CommandInteraction) => { + // @ts-ignore + const { renderChannel } = interaction.client.logger + + let channels = interaction.guild.channels.cache.filter(c => c.type !== "GUILD_CATEGORY"); + let categorised = {} + + await interaction.reply({embeds: [new generateEmojiEmbed() + .setTitle("Loading...") + .setEmoji("NUCLEUS.LOADING") + .setStatus("Success") + ], ephemeral: true}); + for (let c of channels.values()) { + let predicted = [] + let types = { + general: ["general"], + commands: ["bot", "command", "music"], + images: ["pic", "selfies", "image"], + nsfw: ["porn", "nsfw", "sex"], + links: ["links"], + advertising: ["ads", "advert", "server", "partner"], + staff: ["staff", "mod", "admin"] + } + + for (let type in types) { + for (let word of types[type]) { + if (c.name.toLowerCase().includes(word)) { + predicted.push(type) + } + } + } + + await interaction.editReply({embeds: [new generateEmojiEmbed() + .setTitle("Categorise") + .setDescription(generateKeyValueList({ + channel: renderChannel(c), + category: c.parent ? c.parent.name : "Uncategorised" + }) + "\n\n" + `Suggested tags: ${predicted.join(", ")}`) + .setEmoji("CHANNEL.TEXT.CREATE") + .setStatus("Success") + ], components: [ new MessageActionRow().addComponents([ + new MessageButton() + .setLabel("Use suggested") + .setStyle("PRIMARY") + .setCustomId("accept") + .setEmoji(getEmojiByName("CONTROL.RIGHT", "id")) + ]), new MessageActionRow().addComponents([new MessageSelectMenu() + .setPlaceholder("Select a category") + .setCustomId("category") + .setMinValues(0) + .setMaxValues(1) + // .setMaxValues(Object.keys(types).length) + .setOptions(Object.keys(types).map(type => {return {label: toCapitals(type), value: type}})) + ])]}); + } +} + +const check = (interaction: CommandInteraction, defaultCheck: WrappedCheck) => { + return true; +} + +export { command }; +export { callback }; +export { check }; \ No newline at end of file diff --git a/src/commands/createTestButton.ts b/src/commands/createTestButton.ts new file mode 100644 index 0000000..71f9ef1 --- /dev/null +++ b/src/commands/createTestButton.ts @@ -0,0 +1,35 @@ +import { CommandInteraction, MessageActionRow, MessageButton } from "discord.js"; +import { SlashCommandBuilder } from "@discordjs/builders"; +import { WrappedCheck } from "jshaiku"; + +const command = new SlashCommandBuilder() + .setName("createtestbutton") + .setDescription("creates a test button") + +const callback = (interaction: CommandInteraction) => { + interaction.reply({components: [new MessageActionRow().addComponents([ + new MessageButton() + .setCustomId("createticket") + .setLabel("Create Ticket") + .setStyle("PRIMARY") + .setDisabled(false), + new MessageButton() + .setCustomId("verifybutton") + .setLabel("Verify") + .setStyle("PRIMARY") + .setDisabled(false), + new MessageButton() + .setCustomId("rolemenu") + .setLabel("Get roles") + .setStyle("PRIMARY") + .setDisabled(false) + ])]}); +} + +const check = (interaction: CommandInteraction, defaultCheck: WrappedCheck) => { + return true; +} + +export { command }; +export { callback }; +export { check }; \ No newline at end of file diff --git a/src/commands/mod/unnamed.ts b/src/commands/mod/unnamed.ts new file mode 100644 index 0000000..2d65a87 --- /dev/null +++ b/src/commands/mod/unnamed.ts @@ -0,0 +1,233 @@ +import Discord, { CommandInteraction, GuildMember, MessageActionRow } from "discord.js"; +import { SlashCommandSubcommandBuilder } from "@discordjs/builders"; +import { WrappedCheck } from "jshaiku"; +import generateEmojiEmbed from "../../utils/generateEmojiEmbed.js"; +import getEmojiByName from "../../utils/getEmojiByName.js"; +import confirmationMessage from "../../utils/confirmationMessage.js"; +import keyValueList from "../../utils/generateKeyValueList.js"; +import humanizeDuration from "humanize-duration"; +import { create, areTicketsEnabled } from "../../automations/createModActionTicket.js"; +import readConfig from '../../utils/readConfig.js' + +const command = (builder: SlashCommandSubcommandBuilder) => + builder + .setName("unnamed") + .setDescription("Gives a user a role") + .addUserOption(option => option.setName("user").setDescription("The user to UNNAMED").setRequired(true)) // TODO + .addIntegerOption(option => option.setName("days").setDescription("The number of days to UNNAMED the user for | Default 0").setMinValue(0).setMaxValue(27).setRequired(false)) + .addIntegerOption(option => option.setName("hours").setDescription("The number of hours to UNNAMED the user for | Default 0").setMinValue(0).setMaxValue(23).setRequired(false)) + .addIntegerOption(option => option.setName("minutes").setDescription("The number of minutes to UNNAMED the user for | Default 0").setMinValue(0).setMaxValue(59).setRequired(false)) + .addIntegerOption(option => option.setName("seconds").setDescription("The number of seconds to UNNAMED the user for | Default 0").setMinValue(0).setMaxValue(59).setRequired(false)) + .addStringOption(option => option.setName("reason").setDescription("The reason for the UNNAMED").setRequired(false)) + .addStringOption(option => option.setName("notify").setDescription("If the user should get a message when they are UNNAMED | Default yes").setRequired(false) + .addChoices([["Yes", "yes"], ["No", "no"]])) + +const callback = async (interaction: CommandInteraction) => { + // @ts-ignore + const { log, NucleusColors, renderUser, entry } = interaction.client.logger + let config = await readConfig(interaction.guild.id); + const user = interaction.options.getMember("user") as GuildMember + const reason = interaction.options.getString("reason") + const time = { + days: interaction.options.getInteger("days") || 0, + hours: interaction.options.getInteger("hours") || 0, + minutes: interaction.options.getInteger("minutes") || 0, + seconds: interaction.options.getInteger("seconds") || 0 + } + let muteTime = (time.days * 24 * 60 * 60) + (time.hours * 60 * 60) + (time.minutes * 60) + time.seconds + if (muteTime == 0) { + let m = await interaction.reply({embeds: [ + new generateEmojiEmbed() + .setEmoji("PUNISH.MUTE.GREEN") // TODO + .setTitle("UNNAMED") + .setDescription("How long should the user be UNNAMED") + .setStatus("Success") + ], components: [ + new MessageActionRow().addComponents([ + new Discord.MessageButton() + .setCustomId("1m") + .setLabel("1 Minute") + .setStyle("SECONDARY"), + new Discord.MessageButton() + .setCustomId("10m") + .setLabel("10 Minutes") + .setStyle("SECONDARY"), + new Discord.MessageButton() + .setCustomId("30m") + .setLabel("30 Minutes") + .setStyle("SECONDARY"), + new Discord.MessageButton() + .setCustomId("1h") + .setLabel("1 Hour") + .setStyle("SECONDARY") + ]), + new MessageActionRow().addComponents([ + new Discord.MessageButton() + .setCustomId("6h") + .setLabel("6 Hours") + .setStyle("SECONDARY"), + new Discord.MessageButton() + .setCustomId("12h") + .setLabel("12 Hours") + .setStyle("SECONDARY"), + new Discord.MessageButton() + .setCustomId("1d") + .setLabel("1 Day") + .setStyle("SECONDARY"), + new Discord.MessageButton() + .setCustomId("1w") + .setLabel("1 Week") + .setStyle("SECONDARY") + ]), + new MessageActionRow().addComponents([ + new Discord.MessageButton() + .setCustomId("cancel") + .setLabel("Cancel") + .setStyle("DANGER") + .setEmoji(getEmojiByName("CONTROL.CROSS", "id")) + ]) + ], ephemeral: true, fetchReply: true}) + let component; + try { + component = await (m as Discord.Message).awaitMessageComponent({filter: (m) => m.user.id === interaction.user.id, time: 2.5 * 60 * 1000}); + } catch { return } + component.deferUpdate(); + if (component.customId == "cancel") return interaction.editReply({embeds: [new generateEmojiEmbed() + .setEmoji("PUNISH.MUTE.RED") // TODO + .setTitle("UNNAMED") + .setDescription("UNNAMED cancelled") + .setStatus("Danger") + ]}) + switch (component.customId) { + case "1m": { muteTime = 60; break; } + case "10m": { muteTime = 60 * 10; break; } + case "30m": { muteTime = 60 * 30; break; } + case "1h": { muteTime = 60 * 60; break; } + case "6h": { muteTime = 60 * 60 * 6; break; } + case "12h": { muteTime = 60 * 60 * 12; break; } + case "1d": { muteTime = 60 * 60 * 24; break; } + case "1w": { muteTime = 60 * 60 * 24 * 7; break; } + } + } else { + await interaction.reply({embeds: [ + new generateEmojiEmbed() + .setEmoji("PUNISH.MUTE.GREEN") // TODO + .setTitle("UNNAMED") + .setDescription("Loading...") + .setStatus("Success") + ], ephemeral: true, fetchReply: true}) + } + // TODO:[Modals] Replace this with a modal + let confirmation = await new confirmationMessage(interaction) + .setEmoji("PUNISH.MUTE.RED") // TODO + .setTitle("UNNAMED") + .setDescription(keyValueList({ + "user": `<@!${user.id}> (${user.user.username})`, + "time": `${humanizeDuration(muteTime * 1000, {round: true})}`, + "reason": `\n> ${reason ? reason : "*No reason provided*"}` + }) + + `The user **will${interaction.options.getString("notify") === "no" ? ' not' : ''}** be notified\n\n` + + `Are you sure you want to mute <@!${(interaction.options.getMember("user") as GuildMember).id}>?`) // TODO + .setColor("Danger") + .addCustomCallback( + "Create appeal ticket", !(await areTicketsEnabled(interaction.guild.id)), + () => { create(interaction.guild, interaction.options.getUser("user"), interaction.client)}, + "An appeal ticket was created") +// pluralize("day", interaction.options.getInteger("delete")) +// const pluralize = (word: string, count: number) => { return count === 1 ? word : word + "s" } + .send() + if (confirmation.success) { + let dmd = false + let dm; + try { + if (interaction.options.getString("notify") != "no") { + dm = await (interaction.options.getMember("user") as GuildMember).send({ + embeds: [new generateEmojiEmbed() + .setEmoji("PUNISH.MUTE.RED") // TODO + .setTitle("UNNAMED") + .setDescription(`You have been muted in ${interaction.guild.name}` + // TODO + (interaction.options.getString("reason") ? ` for:\n> ${interaction.options.getString("reason")}` : ".\n\n" + + `You will be unmuted at: at ()`)) // TODO + .setStatus("Danger") + ] + }) + dmd = true + } + } catch {} + try { + await ((interaction.options.getMember("user") as GuildMember).roles.add(interaction.guild.roles.cache.find((r) => r.id === config.moderation.role.role))) + // TODO: Store when to remove the role + } catch { + await interaction.editReply({embeds: [new generateEmojiEmbed() + .setEmoji("PUNISH.MUTE.RED") + .setTitle(`Mute`) + .setDescription("Something went wrong and the user was not UNNAMED") + .setStatus("Danger") + ], components: []}) + if (dmd) await dm.delete() + return + } + let failed = (dmd == false && interaction.options.getString("notify") != "no") + await interaction.editReply({embeds: [new generateEmojiEmbed() + .setEmoji(`PUNISH.MUTE.${failed ? "YELLOW" : "GREEN"}`) // TODO + .setTitle(`Mute`) // TODO + .setDescription("The member was muted" + (failed ? ", but could not be notified" : "")) // TODO + .setStatus(failed ? "Warning" : "Success") + ], components: []}) + let data = { + meta:{ + type: 'memberMute', // TODO + displayName: 'Member Muted', // TODO + calculateType: 'guildMemberPunish', + color: NucleusColors.yellow, + emoji: 'PUNISH.WARN.YELLOW', // TODO + timestamp: new Date().getTime() + }, + list: { + user: entry((interaction.options.getMember("user") as GuildMember).user.id, renderUser((interaction.options.getMember("user") as GuildMember).user)), + mutedBy: entry(interaction.member.user.id, renderUser(interaction.member.user)), // TODO + time: entry(muteTime, `${humanizeDuration(muteTime * 1000, {round: true})}`), + reason: (interaction.options.getString("reason") ? `\n> ${interaction.options.getString("reason")}` : "No reason provided") + }, + hidden: { + guild: interaction.guild.id + } + } + log(data, interaction.client); + } else { + await interaction.editReply({embeds: [new generateEmojiEmbed() + .setEmoji("PUNISH.MUTE.GREEN") // TODO + .setTitle(`Mute`) // TODO + .setDescription("No changes were made") + .setStatus("Success") + ], components: []}) + } +} + +const check = (interaction: CommandInteraction, defaultCheck: WrappedCheck) => { + let member = (interaction.member as GuildMember) + let me = (interaction.guild.me as GuildMember) + let apply = (interaction.options.getMember("user") as GuildMember) + if (member == null || me == null || apply == null) throw "That member is not in the server" + let memberPos = member.roles ? member.roles.highest.position : 0 + let mePos = me.roles ? me.roles.highest.position : 0 + let applyPos = apply.roles ? apply.roles.highest.position : 0 + // Check if Nucleus can UNNAMED the member + if (! (mePos > applyPos)) throw "I do not have a role higher than that member" + // Check if Nucleus has permission to UNNAMED + if (! interaction.guild.me.permissions.has("MANAGE_ROLES")) throw "I do not have the `manage_roles` permission"; + // Do not allow the user to have admin or be the owner + if ((interaction.options.getMember("user") as GuildMember).permissions.has("ADMINISTRATOR") || (interaction.options.getMember("user") as GuildMember).id == interaction.guild.ownerId) throw "You cannot mute an admin or the owner" + // Do not allow muting Nucleus + if ((interaction.member as GuildMember).id == interaction.guild.me.id) throw "I cannot UNNAMED myself" + // Allow the owner to UNNAMED anyone + if ((interaction.member as GuildMember).id == interaction.guild.ownerId) return true + // Check if the user has moderate_members permission + if (! (interaction.member as GuildMember).permissions.has("MODERATE_MEMBERS")) throw "You do not have the `moderate_members` permission"; + // Check if the user is below on the role list + if (! (memberPos > applyPos)) throw "You do not have a role higher than that member" + // Allow UNNAMED + return true +} + +export { command, callback, check }; \ No newline at end of file diff --git a/src/commands/nucleus/guide.ts b/src/commands/nucleus/guide.ts new file mode 100644 index 0000000..814d2f5 --- /dev/null +++ b/src/commands/nucleus/guide.ts @@ -0,0 +1,24 @@ +import Discord, { CommandInteraction, MessageActionRow, MessageButton } from "discord.js"; +import { SlashCommandSubcommandBuilder } from "@discordjs/builders"; +import { WrappedCheck } from "jshaiku"; +import getEmojiByName from "../../utils/getEmojiByName.js"; +import generateEmojiEmbed from "../../utils/generateEmojiEmbed.js"; +import guide from "../../automations/guide.js"; + +const command = (builder: SlashCommandSubcommandBuilder) => + builder + .setName("guide") + .setDescription("Shows the welcome guide for the bot") + + +const callback = async (interaction) => { + guide(interaction.guild, interaction) +} + +const check = (interaction: CommandInteraction, defaultCheck: WrappedCheck) => { + return true +} + +export { command }; +export { callback }; +export { check }; diff --git a/src/commands/nucleus/invite.ts b/src/commands/nucleus/invite.ts new file mode 100644 index 0000000..44934e1 --- /dev/null +++ b/src/commands/nucleus/invite.ts @@ -0,0 +1,30 @@ +import { CommandInteraction, MessageActionRow, MessageButton } from "discord.js"; +import { SlashCommandSubcommandBuilder } from "@discordjs/builders"; +import { WrappedCheck } from "jshaiku"; +import generateEmojiEmbed from "../../utils/generateEmojiEmbed.js"; + +const command = (builder: SlashCommandSubcommandBuilder) => + builder + .setName("invite") + .setDescription("Invites Nucleus to your server") + +const callback = (interaction: CommandInteraction) => { + interaction.reply({embeds: [new generateEmojiEmbed() + .setTitle("Invite") + .setDescription("You can invite Nucleus to your server by clicking the button below") + .setEmoji("NUCLEUS.LOGO") + .setStatus("Danger") + ], components: [new MessageActionRow().addComponents([new MessageButton() + .setLabel("Invite") + .setStyle("LINK") + .setURL(`https://discord.com/api/oauth2/authorize?client_id=${interaction.client.user.id}&permissions=295157886134&scope=bot%20applications.commands`) + ])], ephemeral: true}); +} + +const check = (interaction: CommandInteraction, defaultCheck: WrappedCheck) => { + return true; +} + +export { command }; +export { callback }; +export { check }; \ No newline at end of file diff --git a/src/commands/server/_meta.ts b/src/commands/server/_meta.ts new file mode 100644 index 0000000..379aac6 --- /dev/null +++ b/src/commands/server/_meta.ts @@ -0,0 +1,4 @@ +const name = "server"; +const description = "Commands for the server"; + +export { name, description }; \ No newline at end of file diff --git a/src/commands/server/about.ts b/src/commands/server/about.ts new file mode 100644 index 0000000..054cad3 --- /dev/null +++ b/src/commands/server/about.ts @@ -0,0 +1,50 @@ +import Discord, { CommandInteraction, Guild, MessageActionRow, MessageButton } from "discord.js"; +import { SlashCommandSubcommandBuilder } from "@discordjs/builders"; +import { WrappedCheck } from "jshaiku"; +import generateEmojiEmbed from "../../utils/generateEmojiEmbed.js"; +import getEmojiByName from "../../utils/getEmojiByName.js"; +import generateKeyValueList, { toCapitals } from "../../utils/generateKeyValueList.js"; + +const command = (builder: SlashCommandSubcommandBuilder) => builder + .setName("about") + .setDescription("Shows info about the server") + +const callback = async (interaction: CommandInteraction) => { + let guild = interaction.guild as Guild; + // @ts-ignore + const { renderUser, renderDelta } = interaction.client.logger + interaction.reply({embeds: [new generateEmojiEmbed() + .setTitle("Server Info") + .setStatus("Success") + .setEmoji("GUILD.GREEN") + .setDescription( + generateKeyValueList({ + "name": guild.name, + "id": `\`${guild.id}\``, + "owner": `${renderUser((await guild.fetchOwner()).user)}`, + "created": `${renderDelta(guild.createdTimestamp)}`, + "emojis": `${guild.emojis.cache.size}` + (guild.emojis.cache.size > 1 ? `\n> ${ + guild.emojis.cache.first(10).map((emoji) => `<${emoji.animated ? 'a' : ''}:${emoji.name}:${emoji.id}>`).join(" ") + }` + + (guild.emojis.cache.size > 10 ? ` and ${guild.emojis.cache.size - 10} more` : ``) : ""), + "icon": `[Discord](${guild.iconURL()})`, + "2 factor authentication": `${guild.mfaLevel === "NONE" ? `${getEmojiByName("CONTROL.CROSS")} No` : `${getEmojiByName("CONTROL.TICK")} Yes`}`, + "verification level": `${toCapitals(guild.verificationLevel)}`, + "explicit content filter": `${toCapitals(guild.explicitContentFilter.toString().replace(/_/, " ", ))}`, + "nitro boost level": `${guild.premiumTier != "NONE" ? guild.premiumTier.toString()[-1] : "0"}`, + "channels": `${guild.channels.cache.size}`, + "roles": `${guild.roles.cache.size}`, + "members": `${guild.memberCount}`, + }) + ) + .setThumbnail(guild.iconURL({dynamic: true})) + ], ephemeral: true}); +} + +const check = (interaction: CommandInteraction, defaultCheck: WrappedCheck) => { + return true; +} + +export { command }; +export { callback }; +export { check }; \ No newline at end of file diff --git a/src/commands/user/track.ts b/src/commands/user/track.ts new file mode 100644 index 0000000..da6a37a --- /dev/null +++ b/src/commands/user/track.ts @@ -0,0 +1,174 @@ +import Discord, { CommandInteraction, GuildMember, Message, MessageActionRow, MessageButton } from "discord.js"; +import { SelectMenuOption, SlashCommandSubcommandBuilder } from "@discordjs/builders"; +import { WrappedCheck } from "jshaiku"; +import generateEmojiEmbed from "../../utils/generateEmojiEmbed.js"; +import getEmojiByName from "../../utils/getEmojiByName.js"; +import generateKeyValueList from "../../utils/generateKeyValueList.js"; +import readConfig from "../../utils/readConfig.js"; + +const command = (builder: SlashCommandSubcommandBuilder) => + builder + .setName("track") + .setDescription("Moves a user along a role track") + .addUserOption(option => option.setName("user").setDescription("The user to manage").setRequired(true)) + +const generateFromTrack = (position: number, active: any, size: number, disabled: any) => { + active = active ? "ACTIVE" : "INACTIVE" + disabled = disabled ? "GREY." : "" + if (position == 0 && size == 1) return "TRACKS.SINGLE." + disabled + active + if (position == size - 1) return "TRACKS.VERTICAL.BOTTOM." + disabled + active + if (position == 0) return "TRACKS.VERTICAL.TOP." + disabled + active + return "TRACKS.VERTICAL.MIDDLE." + disabled + active +} + +const callback = async (interaction: CommandInteraction) => { + // @ts-ignore + const { renderUser } = interaction.client.logger + const member = interaction.options.getMember("user") as GuildMember; + const guild = interaction.guild; + let config = await readConfig(guild.id); + await interaction.reply({embeds: [new generateEmojiEmbed() + .setEmoji("NUCLEUS.LOADING") + .setTitle("Loading") + .setStatus("Danger") + ], ephemeral: true}) + let track = 0 + let generated; + const roles = await guild.roles.fetch() + let memberRoles = await member.roles + while (true) { + let data = config.tracks[track] + let managed = data.manageableBy.some(element => {return memberRoles.cache.has(element)}) + let dropdown = new Discord.MessageSelectMenu().addOptions(config.tracks.map((option, index) => { + let hasRoleInTrack = option.track.some(element => {return memberRoles.cache.has(element)}) + return new SelectMenuOption({ + default: index == track, + label: option.name, + value: index.toString(), + description: option.track.length == 0 ? "No" : option.track.length + " role" + (option.track.length == 1 ? "" : "s"), // TODO[s addition] + emoji: interaction.client.emojis.resolve(getEmojiByName("TRACKS.SINGLE." + (hasRoleInTrack ? "ACTIVE" : "INACTIVE"), "id")) + }) + })).setCustomId("select").setMaxValues(1) + let allowed = [] + generated = "**Track:** " + data.name + "\n" + "**Member:** " + renderUser(member.user) + "\n" + generated += (data.nullable ? "Members do not need a role in this track" : "A role in this track is required") + "\n" + generated += (data.retainPrevious ? "When promoted, the user keeps previous roles" : "Members will lose their current role when promoted") + "\n" + generated += "\n" + data.track.map((role, index) => { + let allow = (roles.get(role).position >= (interaction.member as GuildMember).roles.highest.position) && !managed + allowed.push(!allow) + return getEmojiByName(generateFromTrack( + index, + memberRoles.cache.has(role), + data.track.length, + allow + )) + " " + + roles.get(role).name + " [<@&" + roles.get(role).id + ">]" + }).join("\n") + let selected = []; + for (let i = 0; i < data.track.length; i++) { + if (memberRoles.cache.has(data.track[i])) selected.push(data.track[i]) + } + let conflict = data.retainPrevious ? false : selected.length > 1; + let conflictDropdown + let currentRoleIndex + if (conflict) { + generated += `\n\n${getEmojiByName(`PUNISH.WARN.${managed ? "YELLOW" : "RED"}`)} This user has ${selected.length} roles from this track. ` + conflictDropdown = [] + if ( + (roles.get(selected[0]).position < memberRoles.highest.position) || managed + ) { + generated += `In order to promote or demote this user, you must select which role the member should keep.` + selected.forEach(role => { + conflictDropdown.push(new SelectMenuOption({ + label: roles.get(role).name, + value: roles.get(role).id, + })) + }) + conflictDropdown = [new Discord.MessageSelectMenu() + .addOptions(conflictDropdown) + .setCustomId("conflict") + .setMaxValues(1) + .setPlaceholder("Select a role to keep")] + } else { + generated += "You don't have permission to manage one or more of the users roles, and therefore can't select one to keep." + } + } else { + currentRoleIndex = selected.length == 0 ? -1 : data.track.indexOf(selected[0].toString()) + } + let m = await interaction.editReply({embeds: [new generateEmojiEmbed() + .setEmoji("TRACKS.ICON") + .setTitle("Tracks") + .setDescription(`${generated}`) + .setStatus("Success") + ], components: [ + new MessageActionRow().addComponents(dropdown) + ] + .concat(conflict && conflictDropdown.length ? [new MessageActionRow().addComponents(conflictDropdown)] : []) + .concat([ + new MessageActionRow().addComponents([ + new MessageButton() + .setEmoji(getEmojiByName("CONTROL.UP", "id")) + .setLabel("Move up") + .setCustomId("promote") + .setStyle("SUCCESS") + .setDisabled(conflict || currentRoleIndex == 0 || (currentRoleIndex == -1 ? false : !allowed[currentRoleIndex - 1])), + new MessageButton() + .setEmoji(getEmojiByName("CONTROL.DOWN", "id")) + .setLabel("Move down") + .setCustomId("demote") + .setStyle("DANGER") + .setDisabled(conflict || ( + data.nullable ? currentRoleIndex <= -1 : + currentRoleIndex == data.track.length - 1 || currentRoleIndex <= -1 + ) || !allowed[currentRoleIndex]), + ]) + ])}) + let component; + try { + component = await (m as Message).awaitMessageComponent({time: 2.5 * 60 * 1000}); + } catch (e) { + return + } + component.deferUpdate() + if (component.customId == "conflict") { + let rolesToRemove = selected.filter(role => role != component.values[0]) + await member.roles.remove(rolesToRemove) + } else if (component.customId == "promote") { + if ( + currentRoleIndex == -1 ? allowed[data.track.length - 1] : + allowed[currentRoleIndex - 1] && allowed[currentRoleIndex] + ) { + if (currentRoleIndex == -1) { + await member.roles.add(data.track[data.track.length - 1]) + } else if (currentRoleIndex < data.track.length) { + if (!data.retainPrevious) await member.roles.remove(data.track[currentRoleIndex]) + await member.roles.add(data.track[currentRoleIndex - 1]) + } + } + } else if (component.customId == "demote") { + if(allowed[currentRoleIndex]) { + if (currentRoleIndex == data.track.length - 1) { + if (data.nullable) await member.roles.remove(data.track[currentRoleIndex]) + } else if (currentRoleIndex > -1) { + await member.roles.remove(data.track[currentRoleIndex]) + await member.roles.add(data.track[currentRoleIndex + 1]) + } + } + } else if (component.customId == "select") { + track = component.values[0] + } + } +} + +const check = (interaction: CommandInteraction, defaultCheck: WrappedCheck) => { + // Allow the owner to promote anyone + if ((interaction.member as GuildMember).id == interaction.guild.ownerId) return true + // Check if the user has manage_roles permission + if (! (interaction.member as GuildMember).permissions.has("MANAGE_ROLES")) throw "You do not have the `manage_roles` permission"; + // Allow track + return true // TODO: allow if the member has manage perms +} + +export { command }; +export { callback }; +export { check }; \ No newline at end of file diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts new file mode 100644 index 0000000..3470450 --- /dev/null +++ b/src/events/interactionCreate.ts @@ -0,0 +1,21 @@ +import { callback as roleMenu } from "../automations/roleMenu.js" +import verify from "../automations/verify.js"; +import create from "../automations/tickets/create.js"; +import close from "../automations/tickets/delete.js"; + +export const event = 'interactionCreate'; + +async function interactionCreate(interaction) { + if (interaction.componentType === "BUTTON") { + if (interaction.customId === "rolemenu") return await roleMenu(interaction) + if (interaction.customId === "verifybutton") return verify(interaction) + if (interaction.customId === "createticket") return create(interaction) + if (interaction.customId === "closeticket") return close(interaction) + } else if (interaction.componentType === "MESSAGE_COMPONENT") { + console.table(interaction) + } +} + +export async function callback(client, interaction) { + await interactionCreate(interaction) +} \ No newline at end of file diff --git a/src/utils/singleNotify.ts b/src/utils/singleNotify.ts new file mode 100644 index 0000000..4e9e6fe --- /dev/null +++ b/src/utils/singleNotify.ts @@ -0,0 +1,26 @@ +import readConfig from "./readConfig.js"; +import generateEmojiEmbed from "./generateEmojiEmbed.js"; + +let severities = { + "Critical": "Danger", + "Warning": "Warning", + "Info": "Success" +} + +export default async function(client, type: string, guild: string, message: string, severity: string) { + let config = await readConfig(guild); + if (config.singleEventNotifications[type]) return; + // TODO: Set config.singleEventNotifications[type] to true + let channel = await client.channels.fetch(config.logging.staff); + if (!channel) return; + try { + await channel.send({embeds: [new generateEmojiEmbed() + .setTitle(`${severity} notification`) + .setDescription(message) + .setColor(severities[severity]) + .setEmoji("CONTROL.BLOCKCROSS") + ]}) + } catch (err) { + console.error(err) + } +}