Development (#11)

We need this NOW.

---------

Co-authored-by: PineaFan <ash@pinea.dev>
Co-authored-by: pineafan <pineapplefanyt@gmail.com>
Co-authored-by: PineappleFan <PineaFan@users.noreply.github.com>
Co-authored-by: Skyler <skyler3665@gmail.com>
diff --git a/src/reflex/guide.ts b/src/reflex/guide.ts
index 6829ef2..a3027e4 100644
--- a/src/reflex/guide.ts
+++ b/src/reflex/guide.ts
@@ -1,9 +1,9 @@
+import { getCommandMentionByName } from './../utils/getCommandDataByName.js';
 import { LoadingEmbed } from "../utils/defaults.js";
 import Discord, {
     ActionRowBuilder,
     ButtonBuilder,
     MessageComponentInteraction,
-    StringSelectMenuInteraction,
     Guild,
     CommandInteraction,
     GuildTextBasedChannel,
@@ -19,32 +19,43 @@
 export default async (guild: Guild, interaction?: CommandInteraction) => {
     let c: GuildTextBasedChannel | null = guild.publicUpdatesChannel ? guild.publicUpdatesChannel : guild.systemChannel;
     c = c
-        ? c
-        : (guild.channels.cache.find(
-              (ch) =>
-                  [
-                        ChannelType.GuildText,
-                        ChannelType.GuildAnnouncement,
-                        ChannelType.PublicThread,
-                        ChannelType.PrivateThread,
-                        ChannelType.AnnouncementThread
-                  ].includes(ch.type) &&
-                  ch.permissionsFor(guild.roles.everyone).has("SendMessages") &&
-                  ch.permissionsFor(guild.members.me!).has("EmbedLinks")
-          ) as GuildTextBasedChannel | undefined) ?? null;
+    ? c
+    : (guild.channels.cache.find(
+        (ch) =>
+        [
+            ChannelType.GuildText,
+            ChannelType.GuildAnnouncement,
+            ChannelType.PublicThread,
+            ChannelType.PrivateThread,
+            ChannelType.AnnouncementThread
+        ].includes(ch.type) &&
+        ch.permissionsFor(guild.roles.everyone).has("SendMessages") &&
+        ch.permissionsFor(guild.members.me!).has("EmbedLinks")
+        ) as GuildTextBasedChannel | undefined) ?? null;
     if (interaction) c = interaction.channel as GuildTextBasedChannel;
     if (!c) {
         return;
     }
+    let m: Message;
+    if (interaction) {
+        m = (await interaction.reply({
+            embeds: LoadingEmbed,
+            fetchReply: true,
+            ephemeral: true
+        })) as Message;
+    } else {
+        m = await c.send({ embeds: LoadingEmbed });
+    }
+    let page = 0;
     const 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)"
+                        "Thanks for adding Nucleus to your server!\n\n" +
+                            "The next few pages will show what features Nucleus has to offer, and how to enable them.\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")
@@ -55,15 +66,17 @@
         new Embed()
             .setEmbed(
                 new EmojiEmbed()
-                    .setTitle("Logging")
+                    .setTitle("Logs")
                     .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`"
+                            "**General:** These are events like kicks and channel changes etc.\n" +
+                            `> These are standard logs and can be set with ${getCommandMentionByName("settings/logs/general")}\n` +
+                            "**Warnings:** Warnings like NSFW avatars and spam etc. that may require action by a server staff member.\n" +
+                            `> These may require special action by a moderator. You can set the channel with ${getCommandMentionByName("settings/logs/warnings")}\n` +
+                            "**Attachments:** All images sent in the server - Used to keep a record of deleted images\n" +
+                            `> Sent to a separate log channel to avoid spam. This can be set with ${getCommandMentionByName("settings/logs/attachments")}\n` +
+                            `> ${getEmojiByName("NUCLEUS.PREMIUM")} Please note this feature is only available with ${getCommandMentionByName("nucleus/premium")}`
                     )
                     .setEmoji("ICONS.LOGGING")
                     .setStatus("Danger")
@@ -77,27 +90,15 @@
                     .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.`
+                            `These commands are all found under ${getCommandMentionByName(("mod"))}, and they include:\n` +
+                            `${getEmojiByName("PUNISH.WARN.YELLOW")} ${getCommandMentionByName("mod/warn")}: The user is warned (via DM) that they violated server rules. More options given if DMs are disabled.\n` +
+                            `${getEmojiByName("PUNISH.CLEARHISTORY")} ${getCommandMentionByName("mod/purge")}: Deletes messages in a channel, giving options to only delete messages by a certain user.\n` +
+                            `${getEmojiByName("PUNISH.MUTE.YELLOW")} ${getCommandMentionByName("mod/mute")}: Stops users sending messages or joining voice chats.\n` +
+                            `${getEmojiByName("PUNISH.MUTE.GREEN")} ${getCommandMentionByName("mod/unmute")}: Allows user to send messages and join voice chats.\n` +
+                            `${getEmojiByName("PUNISH.KICK.RED")} ${getCommandMentionByName("mod/kick")}: Removes a member from the server. They will be able to rejoin.\n` +
+                            `${getEmojiByName("PUNISH.SOFTBAN")} ${getCommandMentionByName("mod/softban")}: Kicks the user, deleting their messages from every channel in a given time frame.\n` +
+                            `${getEmojiByName("PUNISH.BAN.RED")} ${getCommandMentionByName("mod/ban")}: Removes the user from the server, deleting messages from every channel and stops them from rejoining.\n` +
+                            `${getEmojiByName("PUNISH.BAN.GREEN")} ${getCommandMentionByName("mod/unban")}: Allows a member to rejoin the server after being banned.`
                     )
                     .setEmoji("PUNISH.BAN.RED")
                     .setStatus("Danger")
@@ -111,9 +112,9 @@
                     .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`"
+                            `This is done by running ${getCommandMentionByName("verify")} which sends a message only the user can see, giving them a link to a website to verify.\n` +
+                            "After the user complete's the check, they are given a role, which can be set to unlock specific channels.\n" +
+                            `You can set the role given with ${getCommandMentionByName("settings/verify")}`
                     )
                     .setEmoji("CONTROL.REDTICK")
                     .setStatus("Danger")
@@ -127,8 +128,8 @@
                     .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
+                            "The staff team can be notified when an NSFW image is detected, or malicious links are sent.\n" +
+                            `You can check and manage what to moderate in ${getCommandMentionByName("settings/automod")}`
                     )
                     .setEmoji("MOD.IMAGES.TOOSMALL")
                     .setStatus("Danger")
@@ -141,10 +142,12 @@
                 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."
+                        "Nucleus has a ticket system which allows users to create tickets and talk to the server staff or support team.\n" +
+                            `Tickets can be created by users with ${getCommandMentionByName("ticket/create")}, or by clicking a button created by moderators.\n` +
+                            `After being created, a new channel or thread is created, and the user and support team are pinged. \n` +
+                            `The category or channel to create threads in can be set with ${getCommandMentionByName("settings/tickets")}\n` +
+                            `When the ticket is resolved, anyone can run ${getCommandMentionByName("ticket/close")} (or click the button) to close it.\n` +
+                            `Running ${getCommandMentionByName("ticket/close")} again will delete the ticket.`
                     )
                     .setEmoji("GUILD.TICKET.CLOSE")
                     .setStatus("Danger")
@@ -157,11 +160,10 @@
                 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 <tag name> <tag content>`.\n" +
-                            "Tag names and content can be edited with `/tags edit`.\n" +
-                            "To delete a tag, type `/tags delete <tag name>`.\n" +
-                            "To view all tags, type `/tags list`.\n"
+                        "Nucleus allows you to create tags, which allow a message to be sent when a specific tag is typed.\n" +
+                            `Tags can be created with ${getCommandMentionByName("tags/create")}, and can be edited with ${getCommandMentionByName("tags/edit")}\n` +
+                            `Tags can be deleted with ${getCommandMentionByName("tags/delete")}, and can be listed with ${getCommandMentionByName("tags/list")}\n` +
+                            `To use a tag, you can type ${getCommandMentionByName("tag")}, followed by the tag to send`
                     )
                     .setEmoji("PUNISH.NICKNAME.RED")
                     .setStatus("Danger")
@@ -174,29 +176,18 @@
                 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"
+                        "Nucleus Premium allows you to use extra features in your server, which are useful but not essential.\n" +
+                        "**No currently free commands will become premium features.**\n" +
+                        "Premium features include creating ticket transcripts and attachment logs.\n\n" +
+                        "Premium can be purchased in [our server](https://discord.gg/bPaNnxe) in the subscriptions page" // TODO: add a table graphic
                     )
-                    .setEmoji("NUCLEUS.COMMANDS.LOCK")
+                    .setEmoji("NUCLEUS.PREMIUM")
                     .setStatus("Danger")
             )
             .setTitle("Premium")
             .setDescription("Premium features")
             .setPageId(7)
     ];
-    let m: Message;
-    if (interaction) {
-        m = (await interaction.reply({
-            embeds: LoadingEmbed,
-            fetchReply: true,
-            ephemeral: true
-        })) as Message;
-    } else {
-        m = await c.send({ embeds: LoadingEmbed });
-    }
-    let page = 0;
 
     const publicFilter = async (component: MessageComponentInteraction) => {
         return (component.member as Discord.GuildMember).permissions.has("ManageGuild");
@@ -274,7 +265,7 @@
             timedOut = true;
             continue;
         }
-        i.deferUpdate();
+        await i.deferUpdate();
         if (!("customId" in i.component)) {
             continue;
         } else if (i.component.customId === "left") {
@@ -285,8 +276,8 @@
             selectPaneOpen = false;
         } else if (i.component.customId === "select") {
             selectPaneOpen = !selectPaneOpen;
-        } else if (i.component.customId === "page") {
-            page = parseInt((i as StringSelectMenuInteraction).values[0]!);
+        } else if (i.isStringSelectMenu() && i.component.customId === "page") {
+            page = parseInt(i.values[0]!);
             selectPaneOpen = false;
         } else {
             cancelled = true;
diff --git a/src/reflex/scanners.ts b/src/reflex/scanners.ts
index 9761e4b..cf713e6 100644
--- a/src/reflex/scanners.ts
+++ b/src/reflex/scanners.ts
@@ -1,23 +1,27 @@
 import fetch from "node-fetch";
-import FormData from "form-data";
-import { writeFileSync, createReadStream } from "fs";
+import fs, { writeFileSync, createReadStream } from "fs";
 import generateFileName from "../utils/temp/generateFileName.js";
 import Tesseract from "node-tesseract-ocr";
 import type Discord from "discord.js";
 import client from "../utils/client.js";
+import { createHash } from "crypto";
 
 interface NSFWSchema {
     nsfw: boolean;
+    errored?: boolean;
 }
 interface MalwareSchema {
     safe: boolean;
+    errored?: boolean;
 }
 
 export async function testNSFW(link: string): Promise<NSFWSchema> {
-    const p = await saveAttachment(link);
-    const data = new FormData();
-    console.log(link);
-    data.append("file", createReadStream(p));
+    const [p, hash] = await saveAttachment(link);
+    const alreadyHaveCheck = await client.database.scanCache.read(hash)
+    if(alreadyHaveCheck) return { nsfw: alreadyHaveCheck.data };
+    const data = new URLSearchParams();
+    const r = createReadStream(p)
+    data.append("file", r.read(fs.statSync(p).size));
     const result = await fetch("https://unscan.p.rapidapi.com/", {
         method: "POST",
         headers: {
@@ -26,20 +30,24 @@
         },
         body: data
     })
-        .then((response) => response.json() as Promise<NSFWSchema>)
+        .then((response) => response.status === 200 ? response.json() as Promise<NSFWSchema> : { nsfw: false, errored: true })
         .catch((err) => {
             console.error(err);
-            return { nsfw: false };
+            return { nsfw: false, errored: true };
         });
-    console.log(result);
+    if(!result.errored) {
+        client.database.scanCache.write(hash, result.nsfw);
+    }
     return { nsfw: result.nsfw };
 }
 
 export async function testMalware(link: string): Promise<MalwareSchema> {
-    const p = await saveAttachment(link);
-    const data = new FormData();
-    data.append("file", createReadStream(p));
-    console.log(link);
+    const [p, hash] = await saveAttachment(link);
+    const alreadyHaveCheck = await client.database.scanCache.read(hash)
+    if(alreadyHaveCheck) return { safe: alreadyHaveCheck.data };
+    const data = new URLSearchParams();
+    const f = createReadStream(p);
+    data.append("file", f.read(fs.statSync(p).size));
     const result = await fetch("https://unscan.p.rapidapi.com/malware", {
         method: "POST",
         headers: {
@@ -48,18 +56,21 @@
         },
         body: data
     })
-        .then((response) => response.json() as Promise<MalwareSchema>)
+        .then((response) => response.status === 200 ? response.json() as Promise<MalwareSchema> : { safe: true, errored: true })
         .catch((err) => {
             console.error(err);
-            return { safe: true };
+            return { safe: true, errored: true };
         });
-    console.log(result);
+    if (!result.errored) {
+        client.database.scanCache.write(hash, result.safe);
+    }
     return { safe: result.safe };
 }
 
 export async function testLink(link: string): Promise<{ safe: boolean; tags: string[] }> {
-    console.log(link);
-    const scanned: { safe?: boolean; tags?: string[] } = await fetch("https://unscan.p.rapidapi.com/malware", {
+    const alreadyHaveCheck = await client.database.scanCache.read(link)
+    if(alreadyHaveCheck) return { safe: alreadyHaveCheck.data, tags: [] };
+    const scanned: { safe?: boolean; tags?: string[] } = await fetch("https://unscan.p.rapidapi.com/link", {
         method: "POST",
         headers: {
             "X-RapidAPI-Key": client.config.rapidApiKey,
@@ -72,18 +83,19 @@
             console.error(err);
             return { safe: true, tags: [] };
         });
-    console.log(scanned);
+    client.database.scanCache.write(link, scanned.safe ?? true, []);
     return {
         safe: scanned.safe ?? true,
         tags: scanned.tags ?? []
     };
 }
 
-export async function saveAttachment(link: string): Promise<string> {
-    const image = (await (await fetch(link)).buffer()).toString("base64");
+export async function saveAttachment(link: string): Promise<[string, string]> {
+    const image = await (await fetch(link)).arrayBuffer()
     const fileName = generateFileName(link.split("/").pop()!.split(".").pop()!);
-    writeFileSync(fileName, image, "base64");
-    return fileName;
+    const enc = new TextDecoder("utf-8");
+    writeFileSync(fileName, new DataView(image), "base64");
+    return [fileName, createHash('sha512').update(enc.decode(image), 'base64').digest('base64')];
 }
 
 const linkTypes = {
@@ -139,8 +151,7 @@
 
 export async function NSFWCheck(element: string): Promise<boolean> {
     try {
-        const test = await testNSFW(element);
-        return test.nsfw;
+        return (await testNSFW(element)).nsfw;
     } catch {
         return false;
     }
diff --git a/src/reflex/statsChannelUpdate.ts b/src/reflex/statsChannelUpdate.ts
index db705d9..daa82fd 100644
--- a/src/reflex/statsChannelUpdate.ts
+++ b/src/reflex/statsChannelUpdate.ts
@@ -1,4 +1,4 @@
-import { getCommandMentionByName } from '../utils/getCommandMentionByName.js';
+import { getCommandMentionByName } from '../utils/getCommandDataByName.js';
 import type { Guild, User } from "discord.js";
 import type { NucleusClient } from "../utils/client.js";
 import type { GuildMember } from "discord.js";
@@ -32,7 +32,7 @@
                 return singleNotify(
                     "statsChannelDeleted",
                     guild!.id,
-                    `One or more of your stats channels have been deleted. You can use ${await getCommandMentionByName("settings/stats")}.\n` +
+                    `One or more of your stats channels have been deleted. You can use ${getCommandMentionByName("settings/stats")}.\n` +
                         `The channels name was: ${deleted!.name}`,
                     "Critical"
                 );
diff --git a/src/reflex/verify.ts b/src/reflex/verify.ts
index 573da5e..290e372 100644
--- a/src/reflex/verify.ts
+++ b/src/reflex/verify.ts
@@ -13,6 +13,8 @@
 import { TestString, NSFWCheck } from "./scanners.js";
 import createPageIndicator from "../utils/createPageIndicator.js";
 import client from "../utils/client.js";
+import singleNotify from "../utils/singleNotify.js";
+import { getCommandMentionByName } from "../utils/getCommandDataByName.js";
 
 export interface VerifySchema {
     uID: string;
@@ -182,14 +184,14 @@
     let itt = 0;
     const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
     do {
-        itt += 1;
+        itt ++;
         code = "";
         for (let i = 0; i < length; i++) {
             code += chars.charAt(Math.floor(Math.random() * chars.length));
         }
         if (itt > 1000) {
             itt = 0;
-            length += 1;
+            length ++;
         }
     } while (code in verify);
     const role: Role | null = await interaction.guild!.roles.fetch(config.verify.role);
@@ -206,7 +208,8 @@
                     .setEmoji("CONTROL.BLOCKCROSS")
             ]
         });
-        return; // TODO: SEN
+        singleNotify("verifyRoleDeleted", interaction.guild!.id, `The role given when a member is verified has been deleted. Use ${getCommandMentionByName("settings/verify")} to set a new one`, "Critical")
+        return;
     }
     verify[code] = {
         uID: interaction.member!.user.id,
diff --git a/src/reflex/welcome.ts b/src/reflex/welcome.ts
index 87bb81a..c2eede3 100644
--- a/src/reflex/welcome.ts
+++ b/src/reflex/welcome.ts
@@ -1,4 +1,4 @@
-import { getCommandMentionByName } from './../utils/getCommandMentionByName.js';
+import { getCommandMentionByName } from './../utils/getCommandDataByName.js';
 import type { NucleusClient } from "../utils/client.js";
 import convertCurlyBracketString from "../utils/convertCurlyBracketString.js";
 import client from "../utils/client.js";
@@ -27,19 +27,19 @@
                 });
             } else {
                 const channel: GuildChannel | null = await member.guild.channels.fetch(config.welcome.channel) as GuildChannel | null;
-                if (!channel) return await singleNotify("welcomeChannelDeleted", member.guild.id, `The welcome channel has been deleted or is no longer accessible. Use ${await getCommandMentionByName("settings/welcome")} to set a new one`, "Warning")
+                if (!channel) return await singleNotify("welcomeChannelDeleted", member.guild.id, `The welcome channel has been deleted or is no longer accessible. Use ${getCommandMentionByName("settings/welcome")} to set a new one`, "Warning")
                 if (!(channel instanceof BaseGuildTextChannel)) return;
                 if (channel.guild.id !== member.guild.id) return;
                 try {
                     await channel.send({
                         embeds: [new EmojiEmbed().setDescription(string).setStatus("Success")],
-                        content: (config.welcome.ping ? `<@${config.welcome.ping}>` : "") + `<@${member.id}>`
+                        content: (config.welcome.ping ? `<@&${config.welcome.ping}>` : "") + `<@${member.id}>`
                     });
                 } catch (err) {
                     singleNotify(
                         "welcomeChannelDeleted",
                         member.guild.id,
-                        `The welcome channel has been deleted or is no longer accessible. Use ${await getCommandMentionByName("settings/welcome")} to set a new one`,
+                        `The welcome channel has been deleted or is no longer accessible. Use ${getCommandMentionByName("settings/welcome")} to set a new one`,
                         "Warning"
                     )
                 }