From 813bdf45f3ae6f8815593576a4f0a6685f1dd2f3 Mon Sep 17 00:00:00 2001 From: pineafan Date: Sun, 24 Jul 2022 10:39:10 +0100 Subject: [PATCH] forgot to add files --- src/actions/createModActionTicket.ts | 94 +++++++++ src/actions/roleMenu.ts | 157 ++++++++++++++ src/actions/tickets/create.ts | 206 ++++++++++++++++++ src/actions/tickets/delete.ts | 163 ++++++++++++++ src/commands/nucleus/premium.ts | 31 +++ src/events/messageCreate.ts | 303 +++++++++++++++++++++++++++ src/premium/attachmentLogs.ts | 61 ++++++ src/premium/createTranscript.ts | 104 +++++++++ src/reflex/guide.ts | 254 ++++++++++++++++++++++ src/reflex/scanners.ts | 119 +++++++++++ src/reflex/statsChannelAdd.ts | 33 +++ src/reflex/statsChannelRemove.ts | 30 +++ src/reflex/verify.ts | 142 +++++++++++++ src/reflex/welcome.ts | 43 ++++ src/utils/createLogException.ts | 8 + 15 files changed, 1748 insertions(+) create mode 100644 src/actions/createModActionTicket.ts create mode 100644 src/actions/roleMenu.ts create mode 100644 src/actions/tickets/create.ts create mode 100644 src/actions/tickets/delete.ts create mode 100644 src/commands/nucleus/premium.ts create mode 100644 src/events/messageCreate.ts create mode 100644 src/premium/attachmentLogs.ts create mode 100644 src/premium/createTranscript.ts create mode 100644 src/reflex/guide.ts create mode 100644 src/reflex/scanners.ts create mode 100644 src/reflex/statsChannelAdd.ts create mode 100644 src/reflex/statsChannelRemove.ts create mode 100644 src/reflex/verify.ts create mode 100644 src/reflex/welcome.ts create mode 100644 src/utils/createLogException.ts diff --git a/src/actions/createModActionTicket.ts b/src/actions/createModActionTicket.ts new file mode 100644 index 0000000..0162523 --- /dev/null +++ b/src/actions/createModActionTicket.ts @@ -0,0 +1,94 @@ +import Discord, { MessageActionRow, MessageButton } from 'discord.js'; +import EmojiEmbed from '../utils/generateEmojiEmbed.js'; +import getEmojiByName from "../utils/getEmojiByName.js"; +import client from "../utils/client.js"; + +export async function create(guild: Discord.Guild, member: Discord.User, createdBy: Discord.User, reason: string) { + let config = await client.database.guilds.read(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[]; + overwrites.push({ + id: guild.roles.everyone, + deny: ["VIEW_CHANNEL"], + type: "role" + }) + 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 EmojiEmbed() + .setTitle("New Ticket") + .setDescription( + `Ticket created by a Moderator\n` + + `**Support type:** Appeal submission\n` + (reason != null ? `**Reason:**\n> ${reason}\n` : "") + + `**Ticket ID:** \`${c.id}\`\n` + + `Type \`/ticket close\` to close 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: "ticketUpdate", + color: NucleusColors.green, + emoji: 'GUILD.TICKET.OPEN', + timestamp: new Date().getTime() + }, + list: { + ticketFor: entry(member.id, renderUser(member)), + createdBy: entry(createdBy.id, renderUser(createdBy)), + created: entry(new Date().getTime(), renderDelta(new Date().getTime())), + ticketChannel: entry(c.id, renderChannel(c)), + }, + hidden: { + guild: guild.id + } + } + log(data); + } catch (e) { console.log(e); return null } + return c.id +} + +export async function areTicketsEnabled(guild: string) { + let config = await client.database.guilds.read(guild); + return config.tickets.enabled; +} \ No newline at end of file diff --git a/src/actions/roleMenu.ts b/src/actions/roleMenu.ts new file mode 100644 index 0000000..a4397a3 --- /dev/null +++ b/src/actions/roleMenu.ts @@ -0,0 +1,157 @@ +import { Message, MessageButton } from "discord.js"; +import EmojiEmbed from '../utils/generateEmojiEmbed.js' +import { MessageActionRow, MessageSelectMenu } from 'discord.js'; +import getEmojiByName from "../utils/getEmojiByName.js"; +import client from "../utils/client.js"; + +export async function callback(interaction) { + let config = await client.database.guilds.read(interaction.guild.id); + if (!config.roleMenu.enabled) return await interaction.reply({embeds: [new EmojiEmbed() + .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) return await interaction.reply({embeds: [new EmojiEmbed() + .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 EmojiEmbed() + .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 client.roleMenu) continue; + if (itt > 1000) { + itt = 0 + length += 1 + continue; + } + break; + } + client.roleMenu[code] = { + guild: interaction.guild.id, + guildName: interaction.guild.name, + guildIcon: interaction.guild.iconURL({format: "png"}), + user: interaction.member.user.id, + username: interaction.member.user.username, + data: config.roleMenu.options, + interaction: interaction + }; + let up = true + try { + let status = await fetch(client.config.baseUrl).then(res => res.status); + if (status != 200) up = false + } catch { up = false } + m = await interaction.editReply({ + embeds: [new EmojiEmbed() + .setTitle("Roles") + .setDescription("Select how to choose your roles") + .setStatus("Success") + .setEmoji("GUILD.GREEN") + ], components: [new MessageActionRow().addComponents([ + new MessageButton() + .setLabel("Online") + .setStyle("LINK") + .setDisabled(!up) + .setURL(`${client.config.baseUrl}/nucleus/rolemenu?code=${code}`), + new MessageButton() + .setLabel("Manual") + .setStyle("PRIMARY") + .setCustomId("manual") + ])] + }) + } + let component; + try { component = await (m as Message).awaitMessageComponent({time: 300000}); + } 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 EmojiEmbed() + .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: 300000}); + } 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 EmojiEmbed() + .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 EmojiEmbed() + .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") + ], components: []}) + } + await interaction.editReply({embeds: [new EmojiEmbed() + .setTitle("Roles") + .setDescription("Roles have been added. You may close this message.") + .setStatus("Success") + .setEmoji("GUILD.GREEN") + ], components: []}) + return +} diff --git a/src/actions/tickets/create.ts b/src/actions/tickets/create.ts new file mode 100644 index 0000000..e21aa3e --- /dev/null +++ b/src/actions/tickets/create.ts @@ -0,0 +1,206 @@ +import Discord, { MessageActionRow, MessageButton } from "discord.js"; +import { tickets, toHexArray } from "../../utils/calculate.js"; +import client from "../../utils/client.js"; +import EmojiEmbed 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) { + const { log, NucleusColors, entry, renderUser, renderChannel, renderDelta } = client.logger + + let config = await client.database.guilds.read(interaction.guild.id); + if (!config.tickets.enabled || !config.tickets.category) { + return await interaction.reply({embeds: [new EmojiEmbed() + .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 EmojiEmbed() + .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 && config.tickets.useCustom) { 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 += 5) { + splitFormattedTicketTypes.push(new MessageActionRow().addComponents(formattedTicketTypes.slice(i, i + 5))); + } + let m = await interaction.reply({embeds: [new EmojiEmbed() + .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: 300000}); + } 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 += 5) { + splitFormattedTicketTypes.push(new MessageActionRow().addComponents(formattedTicketTypes.slice(i, i + 5))); + } + component.update({embeds: [new EmojiEmbed() + .setTitle("Create Ticket") + .setDescription("Select a ticket type") + .setStatus("Success") + .setEmoji("GUILD.TICKET.OPEN") + ], components: splitFormattedTicketTypes}); + } else { + chosenType = null + await interaction.reply({embeds: [new EmojiEmbed() + .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[]; + overwrites.push({ + id: interaction.guild.roles.everyone, + deny: ["VIEW_CHANNEL"], + type: "role" + }) + 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 EmojiEmbed() + .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 EmojiEmbed() + .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 close 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: "ticketUpdate", + 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); + } catch (e) { console.log(e)} + await interaction.editReply({embeds: [new EmojiEmbed() + .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/actions/tickets/delete.ts b/src/actions/tickets/delete.ts new file mode 100644 index 0000000..1985a08 --- /dev/null +++ b/src/actions/tickets/delete.ts @@ -0,0 +1,163 @@ +import Discord, { MessageButton, MessageActionRow } from "discord.js"; +import client from "../../utils/client.js"; +import EmojiEmbed from "../../utils/generateEmojiEmbed.js"; +import getEmojiByName from "../../utils/getEmojiByName.js"; + +export default async function (interaction) { + const { log, NucleusColors, entry, renderUser, renderChannel, renderDelta } = client.logger + + let config = await client.database.guilds.read(interaction.guild.id); + let thread = false; let threadChannel + if (interaction.channel instanceof Discord.ThreadChannel) thread = true; threadChannel = interaction.channel as Discord.ThreadChannel + let channel = (interaction.channel as Discord.TextChannel) + if (!channel.parent || config.tickets.category != channel.parent.id || (thread ? (threadChannel.parent.parent.id != config.tickets.category) : false)) { + return interaction.reply({embeds: [new EmojiEmbed() + .setTitle("Deleting Ticket...") + .setDescription("This ticket is not in your tickets category, so cannot be deleted. You cannot run close in a thread.") + .setStatus("Danger") + .setEmoji("CONTROL.BLOCKCROSS") + ], ephemeral: true}); + } + let status = channel.topic.split(" ")[1]; + if (status == "Archived") { + await interaction.reply({embeds: [new EmojiEmbed() + .setTitle("Delete Ticket") + .setDescription("Your ticket is being deleted...") + .setStatus("Danger") + .setEmoji("GUILD.TICKET.CLOSE") + ]}); + let data = { + meta:{ + type: 'ticketDeleted', + displayName: 'Ticket Deleted', + calculateType: "ticketUpdate", + 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)), + deletedBy: entry(interaction.member.user.id, renderUser(interaction.member.user)), + deleted: entry(new Date().getTime(), renderDelta(new Date().getTime())) + }, + hidden: { + guild: interaction.guild.id + } + } + log(data); + interaction.channel.delete(); + return; + } else if (status == "Active") { + await interaction.reply({embeds: [new EmojiEmbed() + .setTitle("Close Ticket") + .setDescription("Your ticket is being closed...") + .setStatus("Warning") + .setEmoji("GUILD.TICKET.ARCHIVED") + ]}); + let overwrites = [ + { + id: channel.topic.split(" ")[0], + deny: ["VIEW_CHANNEL"], + type: "member" + }, + { + id: interaction.guild.id, + deny: ["VIEW_CHANNEL"], + type: "role" + } + ] 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" + }) + } + channel.edit({permissionOverwrites: overwrites}) + channel.setTopic(`${channel.topic.split(" ")[0]} Archived`); + let data = { + meta:{ + type: 'ticketClosed', + displayName: 'Ticket Closed', + calculateType: "ticketUpdate", + 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)), + closedBy: entry(interaction.member.user.id, renderUser(interaction.member.user)), + closed: entry(new Date().getTime(), renderDelta(new Date().getTime())), + ticketChannel: entry(channel.id, renderChannel(channel)), + }, + hidden: { + guild: interaction.guild.id + } + } + log(data); + await interaction.editReply({embeds: [new EmojiEmbed() + .setTitle("Close Ticket") + .setDescription("This ticket has been closed.\nType `/ticket close` again to delete it.\n\nNote: Check `/privacy` for details about transcripts.") + .setStatus("Warning") + .setEmoji("GUILD.TICKET.ARCHIVED") + ], components: [ + new MessageActionRow().addComponents([ + new MessageButton() + .setLabel("Delete") + .setStyle("DANGER") + .setCustomId("closeticket") + .setEmoji(getEmojiByName("CONTROL.CROSS", "id")) + ].concat(client.database.premium.hasPremium(interaction.guild.id) ? [ + new MessageButton() + .setLabel("Create Transcript and Delete") + .setStyle("PRIMARY") + .setCustomId("createtranscript") + .setEmoji(getEmojiByName("CONTROL.DOWNLOAD", "id")) + ] : [])) + ]}); + return; + } +} + +async function purgeByUser(member, guild) { + let config = await client.database.guilds.read(guild.id); + if (!config.tickets.category) return; + let tickets = guild.channels.cache.get(config.tickets.category); + if (!tickets) return; + let ticketChannels = tickets.children; + let deleted = 0 + ticketChannels.forEach(element => { + if (element.type != "GUILD_TEXT") return; + if (element.topic.split(" ")[0] == member) { + try { element.delete(); } catch {} + deleted++ + } + }); + if (deleted) { + try { + const { log, NucleusColors, entry, renderUser, renderDelta } = member.client.logger + let data = { + meta:{ + type: 'ticketPurge', + displayName: 'Tickets Purged', + calculateType: "ticketUpdate", + 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); + } catch {} + } +} + +export { purgeByUser } \ No newline at end of file diff --git a/src/commands/nucleus/premium.ts b/src/commands/nucleus/premium.ts new file mode 100644 index 0000000..740ab7b --- /dev/null +++ b/src/commands/nucleus/premium.ts @@ -0,0 +1,31 @@ +import { CommandInteraction, MessageActionRow, MessageButton } from "discord.js"; +import { SlashCommandSubcommandBuilder } from "@discordjs/builders"; +import { WrappedCheck } from "jshaiku"; +import EmojiEmbed from "../../utils/generateEmojiEmbed.js"; + +const command = (builder: SlashCommandSubcommandBuilder) => + builder + .setName("premium") + .setDescription("Information about Nucleus Premium") + +const callback = (interaction: CommandInteraction) => { + interaction.reply({embeds: [new EmojiEmbed() + .setTitle("Premium") + .setDescription( + "*Nucleus Premium is currently not available.*\n\n" + + "Premium allows your server to get access to extra features, for a fixed price per month.\nThis includes:\n" + + "- Attachment logs - Stores attachments so they can be viewed after a message is deleted.\n" + + "- Ticket Transcripts - Gives a link to view the history of a ticket after it has been closed.\n" + ) + .setEmoji("NUCLEUS.LOGO") + .setStatus("Danger") + ], 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/events/messageCreate.ts b/src/events/messageCreate.ts new file mode 100644 index 0000000..7db7d39 --- /dev/null +++ b/src/events/messageCreate.ts @@ -0,0 +1,303 @@ +import { LinkCheck, MalwareCheck, NSFWCheck, SizeCheck, TestString, TestImage } from '../reflex/scanners.js' +import logAttachment from '../premium/attachmentLogs.js' +import createLogException from '../utils/createLogException.js' +import { capitalize } from '../utils/generateKeyValueList.js' +import getEmojiByName from '../utils/getEmojiByName.js' + +export const event = 'messageCreate' + +export async function callback(client, message) { + if(!message) return; + if (message.author.bot) return + if (message.channel.type === 'dm') return + + const { log, NucleusColors, entry, renderUser, renderDelta, renderChannel } = client.logger + + let fileNames = await logAttachment(message); + + let content = message.content.toLowerCase() || '' + let config = await client.memory.readGuildInfo(message.guild.id); + const filter = getEmojiByName("ICONS.FILTER") + let attachmentJump = "" + if (config.logging.attachments.saved[message.channel.id + message.id]) { attachmentJump = ` [[View attachments]](${config})` } + let list = { + messageId: entry(message.id, `\`${message.id}\``), + sentBy: entry(message.author.id, renderUser(message.author)), + sentIn: entry(message.channel.id, renderChannel(message.channel)), + deleted: entry(new Date().getTime(), renderDelta(new Date().getTime())), + mentions: message.mentions.users.size, + attachments: entry(message.attachments.size, message.attachments.size + attachmentJump), + repliedTo: entry( + message.reference ? message.reference.messageId : null, + message.reference ? `[[Jump to message]](https://discord.com/channels/${message.guild.id}/${message.channel.id}/${message.reference.messageId})` : "None" + ) + } + + if (config.filters.invite.enabled) { + if (!config.filters.invite.allowed.users.includes(message.author.id) || + !config.filters.invite.allowed.channels.includes(message.channel.id) || + !message.author.roles.cache.some(role => config.filters.invite.allowed.roles.includes(role.id)) + ) { + if ((/(?:https?:\/\/)?discord(?:app)?\.(?:com\/invite|gg)\/[a-zA-Z0-9]+\/?/.test(content))) { + createLogException(message.guild.id, message.channel.id, message.id) + message.delete(); + let data = { + meta: { + type: 'messageDelete', + displayName: 'Message Deleted (Automated, Contained Invite)', + calculateType: 'autoModeratorDeleted', + color: NucleusColors.red, + emoji: 'MESSAGE.DELETE', + timestamp: new Date().getTime() + }, + separate: { + start: filter + " Contained invite\n\n" + (content ? `**Message:**\n\`\`\`${content}\`\`\`` : '**Message:** *Message had no content*'), + }, + list: list, + hidden: { + guild: message.channel.guild.id + } + } + return log(data); + } + } + } + + if (fileNames.files.length > 0) { + fileNames.files.forEach(async element => { + if(!message) return; + let url = element.url ? element.url : element.local + if (url != undefined) { + if(/\.(jpg|jpeg|png|gif|gifv|webm|webp|mp4|wav|mp3|ogg)$/.test(url)) { + if (config.filters.images.NSFW && !message.channel.nsfw) { + if (await NSFWCheck(url)) { + createLogException(message.guild.id, message.channel.id, message.id) + await message.delete() + let data = { + meta: { + type: 'messageDelete', + displayName: 'Message Deleted', + calculateType: 'autoModeratorDeleted', + color: NucleusColors.red, + emoji: 'MESSAGE.DELETE', + timestamp: new Date().getTime() + }, + separate: { + start: filter + " Image detected as NSFW\n\n" + (content ? `**Message:**\n\`\`\`${content}\`\`\`` : '**Message:** *Message had no content*'), + }, + list: list, + hidden: { + guild: message.channel.guild.id + } + } + return log(data); + } + } + if (config.filters.wordFilter.enabled) { + let text = await TestImage(url) + if (config.filters.wordFilter.enabled) { + let check = TestString(text, config.filters.wordFilter.words.loose, config.filters.wordFilter.words.strict) + if(check !== null) { + createLogException(message.guild.id, message.channel.id, message.id) + await message.delete() + let data = { + meta: { + type: 'messageDelete', + displayName: 'Message Deleted', + calculateType: 'autoModeratorDeleted', + color: NucleusColors.red, + emoji: 'MESSAGE.DELETE', + timestamp: new Date().getTime() + }, + separate: { + start: filter + " Image contained filtered word\n\n" + (content ? `**Message:**\n\`\`\`${content}\`\`\`` : '**Message:** *Message had no content*'), + }, + list: list, + hidden: { + guild: message.channel.guild.id + } + } + return log(data); + } + } + } + if (config.filters.images.size) { + if(url.match(/\.+(webp|png|jpg)$/gi)) { + if(!await SizeCheck(element)) { + createLogException(message.guild.id, message.channel.id, message.id) + await message.delete() + let data = { + meta: { + type: 'messageDelete', + displayName: 'Message Deleted', + calculateType: 'autoModeratorDeleted', + color: NucleusColors.red, + emoji: 'MESSAGE.DELETE', + timestamp: new Date().getTime() + }, + separate: { + start: filter + " Image was too small\n\n" + (content ? `**Message:**\n\`\`\`${content}\`\`\`` : '**Message:** *Message had no content*'), + }, + list: list, + hidden: { + guild: message.channel.guild.id + } + } + return log(data); + } + } + } + } + if (config.filters.malware) { + if (!MalwareCheck(url)) { + createLogException(message.guild.id, message.channel.id, message.id) + await message.delete() + let data = { + meta: { + type: 'messageDelete', + displayName: 'Message Deleted', + calculateType: 'autoModeratorDeleted', + color: NucleusColors.red, + emoji: 'MESSAGE.DELETE', + timestamp: new Date().getTime() + }, + separate: { + start: filter + " File detected as malware\n\n" + (content ? `**Message:**\n\`\`\`${content}\`\`\`` : '**Message:** *Message had no content*'), + }, + list: list, + hidden: { + guild: message.channel.guild.id + } + } + return log(data); + } + } + } + }); + } + if(!message) return; + + let linkDetectionTypes = await LinkCheck(message) + if (linkDetectionTypes.length > 0) { + createLogException(message.guild.id, message.channel.id, message.id) + await message.delete() + let data = { + meta: { + type: 'messageDelete', + displayName: `Message Deleted`, + calculateType: 'autoModeratorDeleted', + color: NucleusColors.red, + emoji: 'MESSAGE.DELETE', + timestamp: new Date().getTime() + }, + separate: { + start: filter + ` Link filtered as ${linkDetectionTypes[0].toLowerCase()}\n\n` + (content ? `**Message:**\n\`\`\`${content}\`\`\`` : '**Message:** *Message had no content*'), + }, + list: list, + hidden: { + guild: message.channel.guild.id + } + } + return log(data); + } + if (config.filters.wordFilter.enabled) { + let check = TestString(content, config.filters.wordFilter.words.loose, config.filters.wordFilter.words.strict) + if(check !== null) { + createLogException(message.guild.id, message.channel.id, message.id) + await message.delete() + let data = { + meta: { + type: 'messageDelete', + displayName: 'Message Deleted', + calculateType: 'autoModeratorDeleted', + color: NucleusColors.red, + emoji: 'MESSAGE.DELETE', + timestamp: new Date().getTime() + }, + separate: { + start: filter + ` Message contained filtered word\n\n` + (content ? `**Message:**\n\`\`\`${content}\`\`\`` : '**Message:** *Message had no content*'), + }, + list: list, + hidden: { + guild: message.channel.guild.id + } + } + return log(data); + } + } + + if (!config.filters.pings.allowed.users.includes(message.author.id) || + !config.filters.pings.allowed.channels.includes(message.channel.id) || + !message.author.roles.cache.some(role => config.filters.pings.allowed.roles.includes(role.id)) + ) { + if (config.filters.pings.everyone && message.mentions.everyone) { + let data = { + meta: { + type: 'everyonePing', + displayName: 'Everyone Pinged', + calculateType: 'messageMassPing', + color: NucleusColors.yellow, + emoji: 'MESSAGE.PING.EVERYONE', + timestamp: new Date().getTime() + }, + separate: { + start: content ? `**Message:**\n\`\`\`${content}\`\`\`` : '**Message:** *Message had no content*', + }, + list: list, + hidden: { + guild: message.channel.guild.id + } + } + return log(data); + } + if (config.filters.pings.roles) { + for(let role of message.mentions.roles) { + if(!message) return; + if (!config.filters.pings.allowed.roles.includes(role.id)) { + createLogException(message.guild.id, message.channel.id, message.id) + await message.delete() + let data = { + meta: { + type: 'rolePing', + displayName: 'Role Pinged', + calculateType: 'messageMassPing', + color: NucleusColors.yellow, + emoji: 'MESSAGE.PING.ROLE', + timestamp: new Date().getTime() + }, + separate: { + start: content ? `**Message:**\n\`\`\`${content}\`\`\`` : '**Message:** *Message had no content*', + }, + list: list, + hidden: { + guild: message.channel.guild.id + } + } + return log(data); + } + } + } + if (message.mentions.users.size >= config.filters.pings.mass && config.filters.pings.mass) { + createLogException(message.guild.id, message.channel.id, message.id) + await message.delete() + let data = { + meta: { + type: 'massPing', + displayName: `Mass Ping`, + calculateType: 'messageMassPing', + color: NucleusColors.yellow, + emoji: 'MESSAGE.PING.MASS', + timestamp: new Date().getTime() + }, + separate: { + start: content ? `**Message:**\n\`\`\`${content}\`\`\`` : '**Message:** *Message had no content*', + }, + list: list, + hidden: { + guild: message.channel.guild.id + } + } + return log(data); + } + } +} diff --git a/src/premium/attachmentLogs.ts b/src/premium/attachmentLogs.ts new file mode 100644 index 0000000..e1dfcc0 --- /dev/null +++ b/src/premium/attachmentLogs.ts @@ -0,0 +1,61 @@ +import client from '../utils/client.js'; +import keyValueList from '../utils/generateKeyValueList.js'; +import singleNotify from '../utils/singleNotify.js'; +import { saveAttachment } from '../reflex/scanners.js'; +import EmojiEmbed from '../utils/generateEmojiEmbed.js'; +import addPlural from '../utils/plurals.js'; + + +export default async function logAttachment(message): Promise { + const { renderUser, renderChannel, renderDelta } = client.logger; + let attachments = [] + for (let attachment of message.attachments.values()) { + attachments.push({local: await saveAttachment(attachment.url), url: attachment.url}) + } + let links = message.content.match(/https?:\/\/\S+/gi) || []; + for (let link of links) { + if (link.toLowerCase().match(/\.(jpg|jpeg|png|gif|gifv|webm|webp|mp4|wav|mp3|ogg)$/gi)) { + attachments.push({local: await saveAttachment(link), url: link}) + } + } + if (attachments.length == 0) return {files: []} + if (client.database.premium.hasPremium(message.guild.id)) { + let channel = (await client.database.guilds.read(message.guild.id)).logging.attachments.channel; + if (!channel) { + singleNotify("noAttachmentLogChannel", message.guild.id, "No channel set for attachment logging", "Warning"); + return {files: attachments}; + } + let channelObj = await client.channels.fetch(channel); + if (!channelObj) { + singleNotify("attachmentLogChannelDeleted", message.guild.id, "Attachment history channel was deleted", "Warning"); + return {files: attachments}; + } + let m = await channelObj.send({embeds: [new EmojiEmbed() + .setTitle(`${addPlural(attachments.length, "Attachment")} Sent`) + .setDescription(keyValueList({ + "messageId": `\`${message.id}\``, + "sentBy": renderUser(message.author), + "sentIn": renderChannel(message.channel), + "sent": renderDelta(new Date(message.createdTimestamp)), + }) + `\n[[Jump to message]](${message.url})`) + .setEmoji("ICONS.ATTACHMENT") + .setStatus("Success") + ], files: attachments.map(file => file.local)}); + // await client.database.guilds.write(interaction.guild.id, {[`tags.${name}`]: value}); + client.database.guilds.write( + message.guild.id, + {[`logging.attachments.saved.${message.channel.id}${message.id}`]: m.url}, + ); + return {files: attachments, jump: m.url}; + } else { + return {files: attachments}; + } +} + +export interface AttachmentLogSchema { + files: { + url: string, + local: string; + }[], + jump?: string; +} \ No newline at end of file diff --git a/src/premium/createTranscript.ts b/src/premium/createTranscript.ts new file mode 100644 index 0000000..92b268c --- /dev/null +++ b/src/premium/createTranscript.ts @@ -0,0 +1,104 @@ +import { MessageActionRow, MessageButton, TextChannel } from "discord.js"; +import EmojiEmbed from "../utils/generateEmojiEmbed.js"; +import getEmojiByName from "../utils/getEmojiByName.js"; +import { PasteClient, Publicity, ExpireDate } from "pastebin-api"; +import config from '../config/main.json' assert {type: 'json'}; +import client from "../utils/client.js"; + +const pbClient = new PasteClient(config.pastebinApiKey) + +export default async function (interaction) { + const { log, NucleusColors, entry, renderUser, renderChannel, renderDelta } = client.logger + + let messages = [] + let deleted = 100; + + while (deleted == 100) { + let fetched; + await (interaction.channel as TextChannel).messages.fetch({limit: 100}).then(async (ms) => { + fetched = await (interaction.channel as TextChannel).bulkDelete(ms, true); + }) + deleted = fetched.size + if (fetched) { + messages = messages.concat(fetched.map(m => m)) + } + } + let out = "" + messages.reverse().forEach(message => { + if (!message.author.bot) { + let sentDate = new Date(message.createdTimestamp) + out += `${message.author.username}#${message.author.discriminator} (${message.author.id}) [${sentDate.toUTCString()}]\n` + let lines = message.content.split("\n") + lines.forEach(line => {out += `> ${line}\n`}) + out += `\n\n` + } + }) + let member = interaction.channel.guild.members.cache.get(interaction.channel.topic.split(" ")[0]) + let m; + if (out !== "") { + const url = await pbClient.createPaste({ + code: out, + expireDate: ExpireDate.Never, + name: `Ticket Transcript for ${member.user.username}#${member.user.discriminator} (Created at ${new Date(interaction.channel.createdTimestamp).toDateString()})`, + publicity: Publicity.Unlisted, + }) + let guildConfig = await client.database.guilds.read(interaction.guild.id); + m = await interaction.reply({embeds: [new EmojiEmbed() + .setTitle("Transcript") + .setDescription(`You can view the transcript using the link below. You can save the link for later` + (guildConfig.logging.logs.channel ? + ` or find it in <#${guildConfig.logging.logs.channel}> once you press delete below. After this the channel will be deleted.` + : ".")) + .setStatus("Success") + .setEmoji("CONTROL.DOWNLOAD") + ], components: [new MessageActionRow().addComponents([ + new MessageButton() + .setLabel("View") + .setStyle("LINK") + .setURL(url), + new MessageButton() + .setLabel("Delete") + .setStyle("DANGER") + .setCustomId("close") + .setEmoji(getEmojiByName("CONTROL.CROSS", "id")) + ])], fetchReply: true}); + } else { + m = await interaction.reply({embeds: [new EmojiEmbed() + .setTitle("Transcript") + .setDescription(`The transcript was empty, so no changes were made. To delete this ticket, press the delete button below.`) + .setStatus("Success") + .setEmoji("CONTROL.DOWNLOAD") + ], components: [new MessageActionRow().addComponents([ + new MessageButton() + .setLabel("Delete") + .setStyle("DANGER") + .setCustomId("close") + .setEmoji(getEmojiByName("CONTROL.CROSS", "id")) + ])], fetchReply: true}); + } + let i; + try { + i = await m.awaitMessageComponent({ time: 300000 }); + i.deferUpdate() + } catch (e) { } + let data = { + meta:{ + type: 'ticketDeleted', + displayName: 'Ticket Deleted', + calculateType: "ticketUpdate", + color: NucleusColors.red, + emoji: 'GUILD.TICKET.CLOSE', + timestamp: new Date().getTime() + }, + list: { + ticketFor: entry(interaction.channel.topic.split(" ")[0], renderUser((await interaction.guild.members.fetch(interaction.channel.topic.split(" ")[0])).user)), + deletedBy: entry(interaction.member.user.id, renderUser(interaction.member.user)), + deleted: entry(new Date().getTime(), renderDelta(new Date().getTime())) + }, + hidden: { + guild: interaction.guild.id + } + } + log(data); + await interaction.channel.delete() + return +} \ No newline at end of file diff --git a/src/reflex/guide.ts b/src/reflex/guide.ts new file mode 100644 index 0000000..39974e7 --- /dev/null +++ b/src/reflex/guide.ts @@ -0,0 +1,254 @@ +import { SelectMenuOption } from '@discordjs/builders'; +import Discord, { MessageActionRow, MessageButton } from "discord.js"; +import EmojiEmbed from "../utils/generateEmojiEmbed.js"; +import getEmojiByName from "../utils/getEmojiByName.js"; +import createPageIndicator from "../utils/createPageIndicator.js"; +import client from "../utils/client.js"; + +class Embed { + embed: Discord.MessageEmbed; + title: string; + description: string = ""; + pageId: number = 0; + setEmbed(embed: Discord.MessageEmbed) { this.embed = embed; return this; } + setTitle(title: string) { this.title = title; return this; } + setDescription(description: string) { this.description = description; return this; } + setPageId(pageId: number) { this.pageId = pageId; return this; } +} + +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 Embed() + .setEmbed(new EmojiEmbed() + .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") + ).setTitle("Welcome").setDescription("About Nucleus").setPageId(0), + new Embed() + .setEmbed(new EmojiEmbed() + .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. " + + "These go to to a separate staff notifications channel.\n\n" + + "A general log channel can be set with `/settings log`\n" + + "A warning log channel can be set with `/settings warnings channel`" + ) + .setEmoji("ICONS.LOGGING") + .setStatus("Danger") + ).setTitle("Logging").setDescription("Logging, staff warning logs etc.").setPageId(1), + new Embed() + .setEmbed(new EmojiEmbed() + .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.` + ) + .setEmoji("PUNISH.BAN.RED") + .setStatus("Danger") + ).setTitle("Moderation").setDescription("Basic moderation commands").setPageId(2), + new Embed() + .setEmbed(new EmojiEmbed() + .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`" + ) + .setEmoji("CONTROL.REDTICK") + .setStatus("Danger") + ).setTitle("Verify").setDescription("Captcha verification system").setPageId(3), + new Embed() + .setEmbed(new EmojiEmbed() + .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`" // TODO + ) + .setEmoji("MOD.IMAGES.TOOSMALL") + .setStatus("Danger") + ).setTitle("Content Scanning").setDescription("Content (NSFW, malware, scams) scanning").setPageId(4), + new Embed() + .setEmbed(new EmojiEmbed() + .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` (or click the button) to close it.\n" + + "Running `/ticket close` again will delete the ticket." + ) + .setEmoji("GUILD.TICKET.CLOSE") + .setStatus("Danger") + ).setTitle("Tickets").setDescription("Ticket system").setPageId(5), + new Embed() + .setEmbed(new EmojiEmbed() + .setTitle("Tags") + .setDescription( + "Add a tag system to your server with the `/tag` and `/tags` commands.\n" + + "To create a tag, type `/tags create `.\n" + + "Tag names and content can be edited with `/tags edit`.\n" + + "To delete a tag, type `/tags delete `.\n" + + "To view all tags, type `/tags list`.\n" + ) + .setEmoji("PUNISH.NICKNAME.RED") + .setStatus("Danger") + ).setTitle("Tags").setDescription("Tag system").setPageId(6), + new Embed() + .setEmbed(new EmojiEmbed() + .setTitle("Premium") + .setDescription( + "In the near future, we will be releasing extra premium only features.\n" + + "These features will include:\n\n" + + "**Attachment logs**\n> When a message with attachments is edited or deleted, the logs will also include the images sent.\n" + + "\nPremium is not yet available. Check `/nucleus premium` for updates on features and pricing" + ) + .setEmoji("NUCLEUS.COMMANDS.LOCK") + .setStatus("Danger") + ).setTitle("Premium").setDescription("Premium features").setPageId(7), + ] + let m; + if (interaction) { + m = await interaction.reply({embeds: [ + new EmojiEmbed() + .setTitle("Welcome") + .setDescription(`One moment...`) + .setStatus("Danger") + .setEmoji("NUCLEUS.LOADING") + ], fetchReply: true, ephemeral: true}); + } else { + m = await c.send({embeds: [ + new EmojiEmbed() + .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"); + } + + let selectPaneOpen = false; + + while (true) { + let selectPane = [] + + if (selectPaneOpen) { + let options = []; + pages.forEach(embed => { + options.push(new SelectMenuOption({ + label: embed.title, + value: embed.pageId.toString(), + description: embed.description || "", + })) + }) + selectPane = [new MessageActionRow().addComponents([ + new Discord.MessageSelectMenu() + .addOptions(options) + .setCustomId("page") + .setMaxValues(1) + .setPlaceholder("Choose a page...") + ])] + } + let components = selectPane.concat([new MessageActionRow().addComponents([ + new MessageButton().setCustomId("left").setEmoji(getEmojiByName("CONTROL.LEFT", "id")).setStyle("SECONDARY").setDisabled(page === 0), + new MessageButton().setCustomId("select").setEmoji(getEmojiByName("CONTROL.MENU", "id")).setStyle(selectPaneOpen ? "PRIMARY" : "SECONDARY").setDisabled(false), + 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") + ])]) + if (interaction) { + let em = new Discord.MessageEmbed(pages[page].embed) + em.setDescription(em.description + "\n\n" + createPageIndicator(pages.length, page)); + await interaction.editReply({ + embeds: [em], + components: components + }); + } else { + let em = new Discord.MessageEmbed(pages[page].embed) + em.setDescription(em.description + "\n\n" + createPageIndicator(pages.length, page)); + await m.edit({ + embeds: [em], + components: components, + fetchReply: true + }); + } + let i + try { + i = await m.awaitMessageComponent({filter: interaction ? () => { return true } : f, time: 300000}); + } catch(e) { break } + i.deferUpdate() + if (i.component.customId == "left") { + if (page > 0) page--; + selectPaneOpen = false; + } else if (i.component.customId == "right") { + if (page < pages.length - 1) page++; + selectPaneOpen = false; + } else if (i.component.customId == "select") { + selectPaneOpen = !selectPaneOpen; + } else if (i.component.customId == "page") { + page = parseInt(i.values[0]); + selectPaneOpen = false; + } else { + if (interaction) { + let em = new Discord.MessageEmbed(pages[page].embed) + em.setDescription(em.description + "\n\n" + createPageIndicator(pages.length, page) + " | Message closed"); + interaction.editReply({embeds: [em], components: [new MessageActionRow().addComponents([ + new MessageButton().setCustomId("left").setEmoji(getEmojiByName("CONTROL.LEFT", "id")).setStyle("SECONDARY").setDisabled(true), + new MessageButton().setCustomId("select").setEmoji(getEmojiByName("CONTROL.MENU", "id")).setStyle(selectPaneOpen ? "PRIMARY" : "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) + ])], fetchReply: true}); + } else { + m.delete(); + } + return; + } + } + const { NucleusColors } = client.logger + if (interaction) { + let em = new Discord.MessageEmbed(pages[page].embed) + em.setDescription(em.description + "\n\n" + createPageIndicator(pages.length, page) + " | Message timed out").setColor(NucleusColors.Danger); + await interaction.editReply({ + embeds: [em], + components: [new MessageActionRow().addComponents([ + new MessageButton().setCustomId("left").setEmoji(getEmojiByName("CONTROL.LEFT", "id")).setStyle("SECONDARY").setDisabled(true), + new MessageButton().setCustomId("select").setEmoji(getEmojiByName("CONTROL.MENU", "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 { + let em = new Discord.MessageEmbed(pages[page].embed) + em.setDescription(em.description + "\n\n" + createPageIndicator(pages.length, page) + " | Message timed out").setColor(NucleusColors.Danger); + await m.edit({ + embeds: [em], + components: [new MessageActionRow().addComponents([ + new MessageButton().setCustomId("left").setEmoji(getEmojiByName("CONTROL.LEFT", "id")).setStyle("SECONDARY").setDisabled(true), + new MessageButton().setCustomId("select").setEmoji(getEmojiByName("CONTROL.MENU", "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/reflex/scanners.ts b/src/reflex/scanners.ts new file mode 100644 index 0000000..8ea22f7 --- /dev/null +++ b/src/reflex/scanners.ts @@ -0,0 +1,119 @@ +import * as us from 'unscan' +import fetch from 'node-fetch' +import { writeFileSync } from 'fs' +import generateFileName from '../utils/temp/generateFileName.js' +import Tesseract from 'node-tesseract-ocr'; + + +export async function testNSFW(link: string): Promise { + let p = await saveAttachment(link) + let result = await us.nsfw.file(p) + return result +} + +export async function testMalware(link: string): Promise { + let p = await saveAttachment(link) + let result = await us.malware.file(p) + return result +} + +export async function saveAttachment(link): Promise { + const image = (await (await fetch(link)).buffer()).toString('base64') + let fileName = generateFileName(link.split('/').pop().split('.').pop()) + writeFileSync(fileName, image, 'base64') + return fileName +} + +export async function testLink(link: string): Promise { + return await us.link.scan(link) +} + + +const linkTypes = { + "PHISHING": "Links designed to trick users into clicking on them.", + "DATING": "Dating sites.", + "TRACKERS": "Websites that store or track personal information.", + "ADVERTISEMENTS": "Websites only for ads.", + "FACEBOOK": "Facebook pages. (Facebook has a number of dangerous trackers. Read more on /privacy)", + "AMP": "AMP pages. (AMP is a technology that allows websites to be served by Google. Read more on /privacy)", + "FACEBOOK TRACKERS": "Websites that include trackers from Facebook.", + "IP GRABBERS": "Websites that store your IP address, which shows your approximate location.", + "PORN": "Websites that include pornography.", + "GAMBLING": "Gambling sites, often scams.", + "MALWARE": "Websites which download files designed to break or slow down your device.", + "PIRACY": "Sites which include illegally downloaded material.", + "RANSOMWARE": "Websites which download a program that can steal your data and make you pay to get it back.", + "REDIRECTS": "Sites like bit.ly which could redirect to a malicious site.", + "SCAMS": "Sites which are designed to trick you into doing something.", + "TORRENT": "Websites that download torrent files.", + "HATE": "Websites that spread hate towards groups or individuals.", + "JUNK": "Websites that are designed to make you waste time.", +} +export { linkTypes }; + + +export async function LinkCheck(message): Promise { + let links = message.content.match(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gi) ?? [] + let detections = [] + const promises = links.map(async element => { + try { + if (element.match(/https?:\/\/[a-zA-Z]+\.?discord(app)?\.(com|net)\/?/)) return // Also matches discord.net, not enough of a bug + element = await testLink(element) + } catch {} + detections.push({tags: element.tags || [], safe: element.safe}) + }); + await Promise.all(promises); + let detectionsTypes = detections.map(element => { + let type = Object.keys(linkTypes).find(type => element.tags.includes(type)) + if (type) return type + // if (!element.safe) return "UNSAFE" + return undefined + }).filter(element => element !== undefined) + return detectionsTypes +} + +export async function NSFWCheck(element): Promise { + try { + let test = (await testNSFW(element)) + //@ts-ignore + return test.nsfw + } catch { + return false + } +} + +export async function SizeCheck(element): Promise { + if (element.height == undefined || element.width == undefined) return true + if (element.height < 20 || element.width < 20) return false + return true +} + +export async function MalwareCheck(element): Promise { + try { + //@ts-ignore + return (await scan.testMalware(element)).safe + } catch { + return true + } +} + +export function TestString(string, soft, strict): object | null { + for(let word of strict || []) { + if (string.toLowerCase().includes(word)) { + return {word: word, type: "strict"} + } + } + for(let word of soft) { + for(let word2 of string.match(/[a-z]+/gi) || []) { + if (word2 == word) { + return {word: word, type: "strict"} + } + } + } + return null +} + +export async function TestImage(url): Promise { + let text = await Tesseract.recognize(url, {lang: "eng", oem: 1, psm: 3}) + return text; +} diff --git a/src/reflex/statsChannelAdd.ts b/src/reflex/statsChannelAdd.ts new file mode 100644 index 0000000..32de0ff --- /dev/null +++ b/src/reflex/statsChannelAdd.ts @@ -0,0 +1,33 @@ +import convertCurlyBracketString from '../utils/convertCurlyBracketString.js' +import singleNotify from '../utils/singleNotify.js'; +import client from '../utils/client.js'; + +export async function callback(_, member) { + let config = await client.database.guilds.read(member.guild.id); + + config.stats.forEach(async element => { + if (element.enabled) { + let string = element.text + if (!string) return + string = await convertCurlyBracketString(string, member.id, member.displayName, member.guild.name, member.guild.members) + let channel; + try { + channel = await member.client.channels.fetch(element.channel) + } catch (error) { channel = null } + if (!channel) { + return singleNotify( + "statsChannelDeleted", + member.guild.id, + "One or more of your stats channels have been deleted. Please open the settings menu to change this.", + "Critical" + ) + } + if (channel.guild.id !== member.guild.id) return + try { + await channel.edit({ name: string }) + } catch (err) { + console.error(err) + } + } + }); +} diff --git a/src/reflex/statsChannelRemove.ts b/src/reflex/statsChannelRemove.ts new file mode 100644 index 0000000..c6d4e65 --- /dev/null +++ b/src/reflex/statsChannelRemove.ts @@ -0,0 +1,30 @@ +import client from '../utils/client.js'; +import convertCurlyBracketString from '../utils/convertCurlyBracketString.js' +import singleNotify from '../utils/singleNotify.js'; + +export async function callback(_, member) { + let config = await client.database.guilds.read(member.guild.id); + + config.stats.forEach(async element => { + if (element.enabled) { + let string = element.text + if (!string) return + string = await convertCurlyBracketString(string, member.id, member.displayName, member.guild.name, member.guild.members) + let channel; + try { + channel = await member.client.channels.fetch(element.channel) + } catch { channel = null } + if (!channel) return singleNotify( + "statsChannelDeleted", + member.guild.id, + "One or more of your stats channels have been deleted. Please open the settings menu to change this.", + "Critical" + ) + try { + await channel.edit({ name: string }) + } catch (err) { + console.error(err) + } + } + }); +} \ No newline at end of file diff --git a/src/reflex/verify.ts b/src/reflex/verify.ts new file mode 100644 index 0000000..9d90dde --- /dev/null +++ b/src/reflex/verify.ts @@ -0,0 +1,142 @@ +import Discord, { GuildMember } from "discord.js"; +import EmojiEmbed from "../utils/generateEmojiEmbed.js"; +import fetch from "node-fetch"; +import { TestString, NSFWCheck } from "./scanners.js"; +import createPageIndicator from "../utils/createPageIndicator.js"; +import client from "../utils/client.js"; + +function step(i) { + return "\n\n" + createPageIndicator(5, i); +} + +export default async function(interaction) { + let verify = client.verify + await interaction.reply({embeds: [new EmojiEmbed() + .setTitle("Loading") + .setDescription(step(-1)) + .setStatus("Danger") + .setEmoji("NUCLEUS.LOADING") + ], ephemeral: true, fetchReply: true}); + let config = await client.database.guilds.read(interaction.guild.id); + if ((!config.verify.enabled ) || (!config.verify.role)) return interaction.editReply({embeds: [new EmojiEmbed() + .setTitle("Verify") + .setDescription(`Verify is not enabled on this server`) + .setStatus("Danger") + .setEmoji("CONTROL.BLOCKCROSS") + ], ephemeral: true, fetchReply: true}); + if ((interaction.member as GuildMember).roles.cache.has(config.verify.role)) { + return await interaction.editReply({embeds: [new EmojiEmbed() + .setTitle("Verify") + .setDescription(`You already have the <@&${config.verify.role}> role` + step(0)) + .setStatus("Danger") + .setEmoji("CONTROL.BLOCKCROSS") + ]}); + } + await interaction.editReply({embeds: [new EmojiEmbed() + .setTitle("Verify") + .setDescription(`Checking our servers are up` + step(0)) + .setStatus("Warning") + .setEmoji("NUCLEUS.LOADING") + ]}); + try { + let status = await fetch(client.config.baseUrl).then(res => res.status); + if (status != 200) { + return await interaction.editReply({embeds: [new EmojiEmbed() + .setTitle("Verify") + .setDescription(`Our servers appear to be down, please try again later` + step(0)) + .setStatus("Danger") + .setEmoji("CONTROL.BLOCKCROSS") + ]}); + } + } catch { + return await interaction.editReply({embeds: [new EmojiEmbed() + .setTitle("Verify") + .setDescription(`Our servers appear to be down, please try again later` + step(0)) + .setStatus("Danger") + .setEmoji("CONTROL.BLOCKCROSS") + ], components: [new Discord.MessageActionRow().addComponents([ + new Discord.MessageButton() + .setLabel("Check webpage") + .setStyle("LINK") + .setURL(client.config.baseUrl), + new Discord.MessageButton() + .setLabel("Support") + .setStyle("LINK") + .setURL("https://discord.gg/bPaNnxe") + ])]}); + } + if (config.filters.images.NSFW) { + await interaction.editReply({embeds: [new EmojiEmbed() + .setTitle("Verify") + .setDescription(`Checking your avatar is safe for work` + step(1)) + .setStatus("Warning") + .setEmoji("NUCLEUS.LOADING") + ]}); + if (await NSFWCheck((interaction.member as GuildMember).user.avatarURL({format: "png"}))) { + return await interaction.editReply({embeds: [new EmojiEmbed() + .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` + step(1)) + .setStatus("Danger") + .setEmoji("CONTROL.BLOCKCROSS") + ]}); + } + } + if (config.filters.wordFilter) { + await interaction.editReply({embeds: [new EmojiEmbed() + .setTitle("Verify") + .setDescription(`Checking your name is allowed` + step(2)) + .setStatus("Warning") + .setEmoji("NUCLEUS.LOADING") + ]}); + if (TestString((interaction.member as Discord.GuildMember).displayName, config.filters.wordFilter.words.loose, config.filters.wordFilter.words.strict) !== null) { + return await interaction.editReply({embeds: [new EmojiEmbed() + .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` + step(2)) + .setStatus("Danger") + .setEmoji("CONTROL.BLOCKCROSS") + ]}); + } + } + await interaction.editReply({embeds: [new EmojiEmbed() + .setTitle("Verify") + .setDescription(`One moment...` + step(3)) + .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, + rID: config.verify.role, + rName: (await interaction.guild.roles.fetch(config.verify.role)).name, + uName: interaction.member.user.username, + gName: interaction.guild.name, + gIcon: interaction.guild.iconURL({format: "png"}), + interaction: interaction + } + await interaction.editReply({embeds: [new EmojiEmbed() + .setTitle("Verify") + .setDescription(`Looking good!\nClick the button below to get verified` + step(4)) + .setStatus("Success") + .setEmoji("MEMBER.JOIN") + ], components: [new Discord.MessageActionRow().addComponents([new Discord.MessageButton() + .setLabel("Verify") + .setStyle("LINK") + .setURL(`${client.config.baseUrl}/nucleus/verify?code=${code}`) + ])]}); +} diff --git a/src/reflex/welcome.ts b/src/reflex/welcome.ts new file mode 100644 index 0000000..5b80fbd --- /dev/null +++ b/src/reflex/welcome.ts @@ -0,0 +1,43 @@ +import log from '../utils/log.js' +import convertCurlyBracketString from '../utils/convertCurlyBracketString.js' +import client from '../utils/client.js'; + +export async function callback(_, member) { + if (member.bot) return + let config = await client.database.guilds.read(member.guild.id); + if (!config.welcome.enabled) return + + if (!config.welcome.verificationRequired.role) { + if (config.welcome.welcomeRole) { + try { + await member.roles.add(config.welcome.welcomeRole) + } catch (err) { + console.error(err) + } + } + } + + if (!config.welcome.verificationRequired.message && config.welcome.channel) { + let string = config.welcome.message + if (string) { + string = await convertCurlyBracketString(string, member.id, member.displayName, member.guild.name, member.guild.members) + + if (config.welcome.channel === 'dm') { + try { + await member.send(string) + } catch (err) { + console.error(err) + } + } else { + let channel = await member.client.channels.fetch(config.welcome.channel) + if (channel.guild.id !== member.guild.id) return + if (!channel) return + try { + await channel.send(string) + } catch (err) { + console.error(err) + } + } + } + } +} \ No newline at end of file diff --git a/src/utils/createLogException.ts b/src/utils/createLogException.ts new file mode 100644 index 0000000..0223d2f --- /dev/null +++ b/src/utils/createLogException.ts @@ -0,0 +1,8 @@ +import client from "./client.js"; + +export default function (guild: string, channel: string, message: string) { + client.noLog.push(`${guild}/${channel}/${message}`); + setTimeout(() => { + client.noLog = client.noLog.filter((i) => {return i !== `${guild}/${channel}/${message}`}); + }, 500); +} \ No newline at end of file