diff --git a/package.json b/package.json index c619c61..79bd570 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "dependencies": { "@hokify/agenda": "^6.2.12", + "@octokit/graphql": "^5.0.5", "@tensorflow/tfjs": "^3.18.0", "@tensorflow/tfjs-node": "^3.18.0", "@total-typescript/ts-reset": "^0.3.7", @@ -25,6 +26,7 @@ "node-fetch": "^3.3.0", "node-tesseract-ocr": "^2.2.1", "nsfwjs": "^2.4.2", + "octokit": "^2.0.14", "seedrandom": "^3.0.5", "structured-clone": "^0.2.2", "systeminformation": "^5.17.3" @@ -48,7 +50,7 @@ "lint-list": "echo 'Style checking...'; prettier --check .; echo 'Linting...'; eslint src; echo 'To view errors in more detail, please run `yarn lint`'; true", "lint-ci": "echo 'Style checking...' && prettier --check . && echo 'Linting...' && eslint src", "setup": "node Installer.js", - "win-force-build": "clear | rm -r dist | tsc-suppress", + "win-force-build": "clear | rm -r dist | tsc-suppress | yarn copy-files", "audit-fix": "yarn-audit-fix", "versions": "yarn versions && yarn list && node --version" }, diff --git a/src/commands/help.ts b/src/commands/help.ts index 96971e6..f95eb6e 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -22,7 +22,10 @@ import { capitalize } from "../utils/generateKeyValueList.js"; import { getCommandByName, getCommandMentionByName } from "../utils/getCommandDataByName.js"; import getEmojiByName from "../utils/getEmojiByName.js"; -const command = new SlashCommandBuilder().setName("help").setDescription("Shows help for commands"); +const command = new SlashCommandBuilder() + .setName("help") + .setDescription("Shows help for commands") + .setDMPermission(true); const styles: Record = { help: { emoji: "NUCLEUS.LOGO" }, @@ -127,23 +130,23 @@ const callback = async (interaction: CommandInteraction): Promise => { ); } for (const option of options) { - optionString += `> \`${option.name}\` (${ApplicationCommandOptionType[option.type]}) - ${ - option.description - }\n`; + optionString += ` - \`${option.name}\` (${ApplicationCommandOptionType[option.type] + .replace("Integer", "Number") + .replace("String", "Text")}) - ${option.description}\n`; } const APICommand = client.commands[ "commands/" + currentPath.filter((value) => value !== "" && value !== "none").join("/") ]![0]; let allowedToRun = true; - if (APICommand?.check) { + if (interaction.guild && APICommand?.check) { allowedToRun = await APICommand.check(interaction as Interaction, true); } embed.setDescription( - `${getEmojiByName(styles[currentPath[0]]!.emoji)} **${capitalize(currentData.name)}**\n> ${ + `${getEmojiByName(styles[currentPath[0]]!.emoji)} **${capitalize(currentData.name)}** | ${ currentData.mention }\n\n` + - `> ${currentData.description}\n\n` + + `${currentData.description}\n\n` + (APICommand ? `${getEmojiByName(allowedToRun ? "CONTROL.TICK" : "CONTROL.CROSS")} You ${ allowedToRun ? "" : "don't " diff --git a/src/commands/mod/nick.ts b/src/commands/mod/nick.ts index a8d4ab8..178246a 100644 --- a/src/commands/mod/nick.ts +++ b/src/commands/mod/nick.ts @@ -81,7 +81,7 @@ const callback = async ( "Change nickname", "ICONS.EDIT", "modal", - newNickname ?? "", + { default: newNickname ?? "" }, new ModalBuilder().setTitle("Editing nickname").addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() @@ -103,7 +103,7 @@ const callback = async ( notify = confirmation.components["notify"]!.active; createAppealTicket = confirmation.components["appeal"]!.active; } - if (confirmation.modals) newNickname = confirmation.modals![0]!.value; + if (confirmation.modals) newNickname = confirmation.modals![0]!.values["default"]; } while (!timedOut && !success); if (timedOut || !success) return; let dmSent = false; @@ -222,7 +222,7 @@ const callback = async ( }); }; -const check = async (interaction: CommandInteraction | ButtonInteraction, partial: boolean, target?: GuildMember) => { +const check = (interaction: CommandInteraction | ButtonInteraction, partial: boolean, target?: GuildMember) => { const member = interaction.member as GuildMember; // Check if the user has manage_nicknames permission if (!member.permissions.has("ManageNicknames")) return "You do not have the *Manage Nicknames* permission"; diff --git a/src/commands/nucleus/stats.ts b/src/commands/nucleus/stats.ts index b2658bc..9690258 100644 --- a/src/commands/nucleus/stats.ts +++ b/src/commands/nucleus/stats.ts @@ -21,6 +21,52 @@ import config from "../../config/main.js"; const command = (builder: SlashCommandSubcommandBuilder) => builder.setName("stats").setDescription("Gets the bot's stats"); +const confirm = async (interaction: CommandInteraction) => { + const requiredTexts = [ + "just do it", + "yes, do as i say!", + "clicksminuteper/nucleus", + "i've said it once i'll say it again", + "no, i've changed my mind", + "this incident will be reported", + "coded told me to", + "mini told me to", + "pinea told me to", + "what's a java script", + "it's a feature not a bug", + "that never happened during testing" + ]; + const chosen = requiredTexts[Math.floor(Math.random() * (requiredTexts.length - 1))]!; + + const modal = new ModalBuilder() + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setStyle(TextInputStyle.Short) + .setLabel(`Type "${chosen}" below`) + .setCustomId("confirm") + .setPlaceholder("Guild ID") + .setMinLength(chosen.length) + .setMaxLength(chosen.length) + ) + ) + .setTitle("Admin Panel") + .setCustomId("adminPanel"); + await interaction.showModal(modal); + let out: ModalSubmitInteraction; + try { + out = await interaction.awaitModalSubmit({ + filter: (i) => i.customId === "adminPanel" && i.user.id === interaction.user.id, + time: 300000 + }); + } catch { + return; + } + await out.deferUpdate(); + const typed = out.fields.getTextInputValue("confirm"); + return typed.toLowerCase() === chosen.toLowerCase(); +}; + const callback = async (interaction: CommandInteraction): Promise => { const description = `**Servers:** ${client.guilds.cache.size}\n` + `**Ping:** \`${client.ws.ping * 2}ms\``; const m = await interaction.reply({ @@ -45,11 +91,7 @@ const callback = async (interaction: CommandInteraction): Promise => { ], components: [ new ActionRowBuilder().addComponents( - new ButtonBuilder().setCustomId("admin").setLabel("Admin Panel").setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId("mod:nickname:599498449733550102") - .setLabel("Testing") - .setStyle(ButtonStyle.Primary) + new ButtonBuilder().setCustomId("admin").setLabel("Admin Panel").setStyle(ButtonStyle.Primary) ) ] }); @@ -77,28 +119,30 @@ const callback = async (interaction: CommandInteraction): Promise => { return; // console.log(interaction) if (!("awaitMessageComponent" in channel)) return; - try { - i1 = await channel!.awaitMessageComponent({ - filter: (i) => i.customId === "admin" && i.user.id === interaction.user.id && i.message.id === m.id, - time: 300000 - }); - } catch (e) { - console.log(e); - return; - } - await i1.showModal(modal); - let out: ModalSubmitInteraction; - try { - out = await i1.awaitModalSubmit({ - filter: (i) => i.customId === "adminPanel" && i.user.id === interaction.user.id, - time: 300000 - }); - } catch { - return; - } - await out.deferUpdate(); - const GuildID = out.fields.getTextInputValue("guildID"); - if (!client.guilds.cache.has(GuildID)) { + let GuildID = interaction.guildId; + if (!GuildID) { + try { + i1 = await channel!.awaitMessageComponent({ + filter: (i) => i.customId === "admin" && i.user.id === interaction.user.id && i.message.id === m.id, + time: 300000 + }); + } catch (e) { + console.log(e); + return; + } + await i1.showModal(modal); + let out: ModalSubmitInteraction; + try { + out = await i1.awaitModalSubmit({ + filter: (i) => i.customId === "adminPanel" && i.user.id === interaction.user.id, + time: 300000 + }); + } catch { + return; + } + await out.deferUpdate(); + GuildID = out.fields.getTextInputValue("guildID"); + } else if (!client.guilds.cache.has(GuildID)) { await interaction.editReply({ embeds: [new EmojiEmbed().setTitle("Admin").setDescription("Not in server").setStatus("Danger")], components: [] @@ -110,10 +154,10 @@ const callback = async (interaction: CommandInteraction): Promise => { components: [ new ActionRowBuilder().addComponents( new ButtonBuilder().setCustomId("stats").setLabel("Stats").setStyle(ButtonStyle.Primary), - new ButtonBuilder().setCustomId("leave").setLabel("Leave").setStyle(ButtonStyle.Danger), new ButtonBuilder().setCustomId("data").setLabel("Guild data").setStyle(ButtonStyle.Secondary), - new ButtonBuilder().setCustomId("purge").setLabel("Delete data").setStyle(ButtonStyle.Danger), - new ButtonBuilder().setCustomId("cache").setLabel("Reset cache").setStyle(ButtonStyle.Success) + new ButtonBuilder().setCustomId("cache").setLabel("Reset cache").setStyle(ButtonStyle.Success), + new ButtonBuilder().setCustomId("leave").setLabel("Leave").setStyle(ButtonStyle.Danger), + new ButtonBuilder().setCustomId("purge").setLabel("Delete data").setStyle(ButtonStyle.Danger) ) ] }); @@ -126,9 +170,9 @@ const callback = async (interaction: CommandInteraction): Promise => { } catch { return; } - await i.deferUpdate(); const guild = (await client.guilds.fetch(GuildID)) as Guild | null; if (!guild) { + await i.deferUpdate(); await interaction.editReply({ embeds: [new EmojiEmbed().setTitle("Admin").setDescription("Not in server").setStatus("Danger")], components: [] @@ -136,6 +180,7 @@ const callback = async (interaction: CommandInteraction): Promise => { return; } if (i.customId === "stats") { + await i.deferUpdate(); await interaction.editReply({ embeds: [ new EmojiEmbed() @@ -154,6 +199,13 @@ const callback = async (interaction: CommandInteraction): Promise => { ] }); } else if (i.customId === "leave") { + if (!(await confirm(interaction))) { + await interaction.editReply({ + embeds: [new EmojiEmbed().setTitle("No changes were made").setStatus("Danger")], + components: [] + }); + return; + } await guild.leave(); await interaction.editReply({ embeds: [ @@ -166,6 +218,7 @@ const callback = async (interaction: CommandInteraction): Promise => { components: [] }); } else if (i.customId === "data") { + await i.deferUpdate(); // Get all the data and convert to a string const data = await client.database.guilds.read(guild.id); const stringified = JSON.stringify(data, null, 2); @@ -179,6 +232,13 @@ const callback = async (interaction: CommandInteraction): Promise => { files: [attachment] }); } else if (i.customId === "purge") { + if (!(await confirm(interaction))) { + await interaction.editReply({ + embeds: [new EmojiEmbed().setTitle("No changes were made").setStatus("Danger")], + components: [] + }); + return; + } await client.database.guilds.delete(GuildID); await client.database.history.delete(GuildID); await client.database.notes.delete(GuildID); @@ -194,6 +254,7 @@ const callback = async (interaction: CommandInteraction): Promise => { components: [] }); } else if (i.customId === "cache") { + await i.deferUpdate(); await client.memory.forceUpdate(guild.id); await interaction.editReply({ embeds: [ diff --git a/src/commands/nucleus/suggest.ts b/src/commands/nucleus/suggest.ts index 79a0673..c1f0312 100644 --- a/src/commands/nucleus/suggest.ts +++ b/src/commands/nucleus/suggest.ts @@ -1,61 +1,133 @@ import { LoadingEmbed } from "../../utils/defaults.js"; -import { ButtonStyle, CommandInteraction } from "discord.js"; -import Discord from "discord.js"; +import Discord, { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + CommandInteraction, + ModalBuilder, + TextInputBuilder, + TextInputStyle +} from "discord.js"; import type { SlashCommandSubcommandBuilder } from "discord.js"; import confirmationMessage from "../../utils/confirmationMessage.js"; import EmojiEmbed from "../../utils/generateEmojiEmbed.js"; import client from "../../utils/client.js"; -import getEmojiByName from "../../utils/getEmojiByName.js"; +import config from "../../config/main.js"; +import _ from "lodash"; const command = (builder: SlashCommandSubcommandBuilder) => - builder - .setName("suggest") - .setDescription("Sends a suggestion to the developers") - .addStringOption((option) => - option.setName("suggestion").setDescription("The suggestion to send").setRequired(true) - ); + builder.setName("suggest").setDescription("Sends a suggestion to the developers"); const callback = async (interaction: CommandInteraction): Promise => { await interaction.guild?.members.fetch(interaction.member!.user.id); - const { renderUser } = client.logger; - const suggestion = interaction.options.get("suggestion")?.value as string; await interaction.reply({ embeds: LoadingEmbed, ephemeral: true }); - const confirmation = await new confirmationMessage(interaction) - .setEmoji("ICONS.OPP.ADD") - .setTitle("Suggest") - .setDescription( - `**Suggestion:**\n> ${suggestion}\n` + - "Your username and ID will also be sent with your suggestion.\n\nAre you sure you want to send this suggestion?" - ) - .setColor("Danger") - .setInverted(true) - .setFailedMessage("Your suggestion was deleted", "Success", "ICONS.ADD") - .send(true); - if (confirmation.cancelled || !confirmation.success) return; - await (client.channels.cache.get("955161206459600976") as Discord.TextChannel).send({ + let closed = false; + let suggestionTitle: string | null = null; + let suggestionDesc: string | null = null; + do { + const modal = new ModalBuilder() + .setTitle("Suggestion") + .setComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setLabel("Suggestion Title") + .setRequired(false) + .setStyle(TextInputStyle.Short) + .setCustomId("suggestionTitle") + .setPlaceholder("Summarize your suggestion in 1 sentence...") + .setMaxLength(256) + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setLabel("Suggestion Description") + .setCustomId("suggestionDesc") + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + .setPlaceholder("Put the full details of your suggestion here...") + .setMinLength(50) + ) + ); + const o: { suggestionDesc?: string; suggestionTitle?: string } = {}; + if (suggestionTitle) { + o.suggestionTitle = suggestionTitle; + modal.components[0]!.components[0]!.setValue(suggestionTitle); + } + if (suggestionDesc) { + o.suggestionDesc = suggestionDesc; + modal.components[1]!.components[0]!.setValue(suggestionDesc); + } + const confirmation = await new confirmationMessage(interaction) + .setEmoji("ICONS.ADD") + .setTitle("Suggest") + .setDescription( + suggestionDesc + ? `Are you sure you want to send this suggestion?\n\n**Title ${ + suggestionTitle ? "" : "(*Placeholder*)" + }:**\n> ${ + suggestionTitle ? suggestionTitle : `${suggestionDesc.substring(0, 70)}` + }\n\n**Suggestion:**\n> ${suggestionDesc}` + : "Please enter your suggestion below." + ) + .addModal("Edit Suggestion", "ICONS.EDIT", "editSuggestion", _.cloneDeep(o), modal) + .setColor("Success") + .setInverted(true) + .setFailedMessage("Your suggestion was deleted", "Success", "ICONS.ADD") + .send(true); + if (confirmation.modals?.[0] && !_.isEqual(confirmation.modals[0].values, o)) { + suggestionTitle = confirmation.modals[0].values["suggestionTitle"] as string | null; + suggestionDesc = confirmation.modals[0].values["suggestionDesc"] as string | null; + continue; + } + if (confirmation.cancelled || confirmation.success === false) { + closed = true; + return; + } + if (confirmation.success) { + closed = true; + } + } while (!closed); + if (!suggestionDesc) return; + suggestionTitle = suggestionTitle ? suggestionTitle : `${suggestionDesc.substring(0, 70)}`; + const channel = client.channels.cache.get(config.suggestionChannel) as Discord.TextChannel; + const m = await channel.send({ embeds: LoadingEmbed }); + const issue = await client.GitHub.rest.issues.create({ + owner: "ClicksMinutePer", + repo: "Nucleus", + title: suggestionTitle, + body: `Linked Suggestion in Private Developer Channel: [Message](${ + m.url + })\n\n**Suggestion:**\n> ${suggestionDesc + .replaceAll("@", "@") + .replaceAll("/issues", "/issues") + .replaceAll("/pull", "/pull")}\n\n`, + labels: ["🤖 Auto", "📝 Suggestion"] + }); + await m.edit({ embeds: [ new EmojiEmbed() - .setTitle("Suggestion") - .setDescription( - `**From:** ${renderUser( - interaction.member!.user as Discord.User - )}\n**Suggestion:**\n> ${suggestion}\n\n` + - `**Server:** ${interaction.guild!.name} (${interaction.guild!.id})\n` - ) - .setStatus("Warning") + .setEmoji("ICONS.ADD") + .setTitle(`Suggestion from ${interaction.user.tag} (${interaction.user.id})`) + .setDescription(`**Suggestion:**\n> ${suggestionDesc}\n\n`) + .setStatus("Success") + .setFooter({ text: `${issue.data.number}` }) ], components: [ - new Discord.ActionRowBuilder().addComponents( - new Discord.ButtonBuilder() - .setCustomId("suggestionAccept") - .setLabel("Accept") - .setStyle(ButtonStyle.Secondary) - .setEmoji(getEmojiByName("ICONS.ADD", "id")), - new Discord.ButtonBuilder() - .setCustomId("suggestionDeny") - .setLabel("Delete") - .setStyle(ButtonStyle.Secondary) - .setEmoji(getEmojiByName("ICONS.REMOVE", "id")) + new Discord.ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId("accept:Suggestion").setLabel("Accept").setStyle(ButtonStyle.Success), + new ButtonBuilder().setCustomId("deny:Suggestion").setLabel("Deny").setStyle(ButtonStyle.Danger), + new ButtonBuilder().setCustomId("close:Suggestion").setLabel("Close").setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId("implemented:Suggestion") + .setLabel("Implemented") + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setLabel(`Open Issue #${issue.data.number}`) + .setStyle(ButtonStyle.Link) + .setURL(`https://github.com/ClicksMinutePer/Nucleus/issues/${issue.data.number}`) + ), + new Discord.ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId("lock:Suggestion").setLabel("Lock").setStyle(ButtonStyle.Danger), + new ButtonBuilder().setCustomId("spam:Suggestion").setLabel("Mark as Spam").setStyle(ButtonStyle.Danger) ) ] }); diff --git a/src/commands/privacy.ts b/src/commands/privacy.ts index 3a671ed..587a7bc 100644 --- a/src/commands/privacy.ts +++ b/src/commands/privacy.ts @@ -29,11 +29,11 @@ const callback = async (interaction: CommandInteraction): Promise => { .setDescription( "Nucleus is a bot that naturally needs to store data about servers.\n" + "We are entirely [open source](https://github.com/ClicksMinutePer/Nucleus), so you can check exactly what we store, and how it works.\n\n" + - "Any questions about Nucleus, how it works, and what data is stored can be asked in [our server](https://discord.gg/bPaNnxe)." + "Any questions about Nucleus, how it works, and what data is stored can be asked in [our server](https://discord.gg/bPaNnxe)." + + "\n\n[[Privacy Policy]](https://clicksminuteper.github.io/policies/nucleus) | [[Terms of Service]](https://clicksminuteper.github.io/policies/nucleustos)" ) .setEmoji("NUCLEUS.LOGO") .setStatus("Danger") - .setFooter({ text: "https://clicksminuteper.github.io/policies/nucleus" }) ) .setTitle("Welcome") .setDescription("General privacy information") @@ -43,15 +43,16 @@ const callback = async (interaction: CommandInteraction): Promise => { new EmojiEmbed() .setTitle("Scanners") .setDescription( - "Nucleus uses [unscan](https://rapidapi.com/abcdan/api/unscan/) to scan links, images and files for malware and other threats.\n" + - 'This service\'s [privacy policy](https://unscan.co/policies) is public, and they "do not store or sell your data."' + "Nucleus scans content sent by users for malware and NSFW content\n" + + 'Malware is detected using [ClamAV](https://clamav.net/), and the standard ClamAV database."\n' + + "NSFW detection is provided by [NsfwJS](https://nsfwjs.com/), with a model provided by [GantMan](https://github.com/GantMan/nsfw_model/releases/tag/1.1.0)\n\n" + + "All data is processed on our servers and is not processed by a 3rd party." ) .setEmoji("NUCLEUS.LOGO") .setStatus("Danger") - .setFooter({ text: "https://clicksminuteper.github.io/policies/nucleus" }) ) .setTitle("Scanners") - .setDescription("About Unscan") + .setDescription("About Scanners") .setPageId(1), new Embed() .setEmbed( @@ -62,13 +63,12 @@ const callback = async (interaction: CommandInteraction): Promise => { ) .setEmoji("NUCLEUS.LOGO") .setStatus("Danger") - .setFooter({ text: "https://clicksminuteper.github.io/policies/nucleus" }) ) .setTitle("Link scanning and Transcripts") .setDescription("Information about how links and images are scanned, and transcripts are stored") .setPageId(2) ].concat( - (interaction.member as Discord.GuildMember).permissions.has("Administrator") + (interaction.member as Discord.GuildMember).id === interaction.guild!.ownerId ? [ new Embed() .setEmbed( @@ -77,7 +77,6 @@ const callback = async (interaction: CommandInteraction): Promise => { .setDescription("Below are buttons for controlling this servers privacy settings") .setEmoji("NUCLEUS.LOGO") .setStatus("Danger") - .setFooter({ text: "https://clicksminuteper.github.io/policies/nucleus" }) ) .setTitle("Options") .setDescription("Options") @@ -88,7 +87,6 @@ const callback = async (interaction: CommandInteraction): Promise => { .setLabel("Clear all data") .setCustomId("clear-all-data") .setStyle(ButtonStyle.Danger) - .setDisabled(!(interaction.user.id === interaction.guild!.ownerId)) ]) ]) ] diff --git a/src/commands/settings/automod.ts b/src/commands/settings/automod.ts index b65eb4c..471af80 100644 --- a/src/commands/settings/automod.ts +++ b/src/commands/settings/automod.ts @@ -28,6 +28,7 @@ import client from "../../utils/client.js"; import getEmojiByName from "../../utils/getEmojiByName.js"; import { modalInteractionCollector } from "../../utils/dualCollector.js"; import listToAndMore from "../../utils/listToAndMore.js"; +import _ from "lodash"; const command = (builder: SlashCommandSubcommandBuilder) => builder.setName("automod").setDescription("Setting for automatic moderation features"); @@ -157,6 +158,7 @@ const toSelectMenu = async ( const imageMenu = async ( interaction: StringSelectMenuInteraction, m: Message, + unsavedChanges: boolean, current: { NSFW: boolean; size: boolean; @@ -186,7 +188,10 @@ const imageMenu = async ( .setTitle("Image Settings") .setDescription( `${emojiFromBoolean(current.NSFW)} **NSFW**\n` + `${emojiFromBoolean(current.size)} **Size**\n` - ); + ) + .setFooter({ + text: unsavedChanges ? "No changes made" : "Changes not saved" + }); await interaction.editReply({ embeds: [embed], components: [options] }); @@ -207,10 +212,12 @@ const imageMenu = async ( } case "nsfw": { current.NSFW = !current.NSFW; + unsavedChanges = true; break; } case "size": { current.size = !current.size; + unsavedChanges = true; break; } } @@ -221,6 +228,7 @@ const imageMenu = async ( const wordMenu = async ( interaction: StringSelectMenuInteraction, m: Message, + unsavedChanges: boolean, current: { enabled: boolean; words: { strict: string[]; loose: string[] }; @@ -296,7 +304,10 @@ const wordMenu = async ( ) ) .setStatus("Success") - .setEmoji("GUILD.SETTINGS.GREEN"); + .setEmoji("GUILD.SETTINGS.GREEN") + .setFooter({ + text: unsavedChanges ? "No changes made" : "Changes not saved" + }); await interaction.editReply({ embeds: [embed], components: [selectMenu, buttons] }); @@ -320,6 +331,7 @@ const wordMenu = async ( } case "enabled": { current.enabled = !current.enabled; + unsavedChanges = true; break; } } @@ -391,6 +403,7 @@ const wordMenu = async ( .split(",") .map((s) => s.trim()) .filter((s) => s.length > 0); + unsavedChanges = true; break; } case "allowedUsers": { @@ -402,6 +415,7 @@ const wordMenu = async ( "member", "Word Filter" ); + unsavedChanges = true; break; } case "allowedRoles": { @@ -413,6 +427,7 @@ const wordMenu = async ( "role", "Word Filter" ); + unsavedChanges = true; break; } case "allowedChannels": { @@ -424,6 +439,7 @@ const wordMenu = async ( "channel", "Word Filter" ); + unsavedChanges = true; break; } } @@ -435,6 +451,7 @@ const wordMenu = async ( const inviteMenu = async ( interaction: StringSelectMenuInteraction, m: Message, + unsavedChanges: boolean, current: { enabled: boolean; allowed: { users: string[]; roles: string[]; channels: string[] }; @@ -503,7 +520,10 @@ const inviteMenu = async ( ) ) .setStatus("Success") - .setEmoji("GUILD.SETTINGS.GREEN"); + .setEmoji("GUILD.SETTINGS.GREEN") + .setFooter({ + text: unsavedChanges ? "No changes made" : "Changes not saved" + }); await interaction.editReply({ embeds: [embed], components: [menu, buttons] }); @@ -526,6 +546,7 @@ const inviteMenu = async ( } case "enabled": { current.enabled = !current.enabled; + unsavedChanges = true; break; } } @@ -540,6 +561,7 @@ const inviteMenu = async ( "member", "Invite Settings" ); + unsavedChanges = true; break; } case "roles": { @@ -550,6 +572,7 @@ const inviteMenu = async ( "role", "Invite Settings" ); + unsavedChanges = true; break; } case "channels": { @@ -560,6 +583,7 @@ const inviteMenu = async ( "channel", "Invite Settings" ); + unsavedChanges = true; break; } } @@ -571,6 +595,7 @@ const inviteMenu = async ( const mentionMenu = async ( interaction: StringSelectMenuInteraction, m: Message, + unsavedChanges: boolean, current: { mass: number; everyone: boolean; @@ -690,7 +715,10 @@ const mentionMenu = async ( }` ) .setStatus("Success") - .setEmoji("GUILD.SETTINGS.GREEN"); + .setEmoji("GUILD.SETTINGS.GREEN") + .setFooter({ + text: unsavedChanges ? "No changes made" : "Changes not saved" + }); await interaction.editReply({ embeds: [embed], components: [menu, allowedMenu, buttons] }); @@ -714,10 +742,12 @@ const mentionMenu = async ( } case "everyone": { current.everyone = !current.everyone; + unsavedChanges = true; break; } case "roles": { current.roles = !current.roles; + unsavedChanges = true; break; } } @@ -767,6 +797,7 @@ const mentionMenu = async ( if (!out) break; if (out.isButton()) break; current.mass = parseInt(out.fields.getTextInputValue("mass")); + unsavedChanges = true; break; } case "roles": { @@ -778,6 +809,7 @@ const mentionMenu = async ( "role", "Mention Settings" ); + unsavedChanges = true; break; } } @@ -794,6 +826,7 @@ const mentionMenu = async ( "member", "Mention Settings" ); + unsavedChanges = true; break; } case "roles": { @@ -804,6 +837,7 @@ const mentionMenu = async ( "role", "Mention Settings" ); + unsavedChanges = true; break; } case "channels": { @@ -814,6 +848,7 @@ const mentionMenu = async ( "channel", "Mention Settings" ); + unsavedChanges = true; break; } } @@ -828,6 +863,7 @@ const mentionMenu = async ( const cleanMenu = async ( interaction: StringSelectMenuInteraction, m: Message, + unsavedChanges: boolean, current?: { channels?: string[]; allowed?: { @@ -890,7 +926,10 @@ const cleanMenu = async ( : "None" }\n\n` ) - .setStatus("Success"); + .setStatus("Success") + .setFooter({ + text: unsavedChanges ? "No changes made" : "Changes not saved" + }); await interaction.editReply({ embeds: [embed], components: [channelMenu, allowedMenu, buttons] }); @@ -958,6 +997,7 @@ const cleanMenu = async ( } } } + unsavedChanges = true; break; } case "allowed": { @@ -970,6 +1010,7 @@ const cleanMenu = async ( "member", "Mention Settings" ); + unsavedChanges = true; break; } case "roles": { @@ -980,6 +1021,7 @@ const cleanMenu = async ( "role", "Mention Settings" ); + unsavedChanges = true; break; } } @@ -1001,15 +1043,19 @@ const cleanMenu = async ( const callback = async (interaction: CommandInteraction): Promise => { if (!interaction.guild) return; const m = await interaction.reply({ embeds: LoadingEmbed, fetchReply: true, ephemeral: true }); - const config = (await client.database.guilds.read(interaction.guild.id)).filters; + let config = (await client.database.guilds.read(interaction.guild.id)).filters; let closed = false; - const button = new ActionRowBuilder().addComponents( - new ButtonBuilder().setCustomId("save").setLabel("Save").setStyle(ButtonStyle.Success) - ); - + let current = _.cloneDeep(config); do { + const button = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("save") + .setLabel("Save") + .setStyle(ButtonStyle.Success) + .setDisabled(_.isEqual(config, current)) + ); const selectMenu = new ActionRowBuilder().addComponents( new StringSelectMenuBuilder() .setCustomId("filter") @@ -1055,7 +1101,10 @@ const callback = async (interaction: CommandInteraction): Promise => { `${emojiFromBoolean(config.clean.channels.length > 0)} **Clean**\n` ) .setStatus("Success") - .setEmoji("GUILD.SETTINGS.GREEN"); + .setEmoji("GUILD.SETTINGS.GREEN") + .setFooter({ + text: _.isEqual(config, current) ? "No changes made" : "Changes not saved" + }); await interaction.editReply({ embeds: [embed], components: [selectMenu, button] }); @@ -1069,41 +1118,37 @@ const callback = async (interaction: CommandInteraction): Promise => { closed = true; continue; } + await i.deferUpdate(); if (i.isButton()) { - await i.deferUpdate(); - await client.database.guilds.write(interaction.guild.id, { filters: config }); + await client.database.guilds.write(interaction.guild.id, { filters: current }); await client.memory.forceUpdate(interaction.guild.id); + config = current; + current = _.cloneDeep(config); } else { switch (i.values[0]) { case "invites": { - await i.deferUpdate(); - config.invite = await inviteMenu(i, m, config.invite); + config.invite = await inviteMenu(i, m, _.isEqual(config, current), config.invite); break; } case "mentions": { - await i.deferUpdate(); - config.pings = await mentionMenu(i, m, config.pings); + config.pings = await mentionMenu(i, m, _.isEqual(config, current), config.pings); break; } case "words": { - await i.deferUpdate(); - config.wordFilter = await wordMenu(i, m, config.wordFilter); + config.wordFilter = await wordMenu(i, m, _.isEqual(config, current), config.wordFilter); break; } case "malware": { - await i.deferUpdate(); config.malware = !config.malware; break; } case "images": { - await i.deferUpdate(); - const next = await imageMenu(i, m, config.images); + const next = await imageMenu(i, m, _.isEqual(config, current), config.images); config.images = next; break; } case "clean": { - await i.deferUpdate(); - const next = await cleanMenu(i, m, config.clean); + const next = await cleanMenu(i, m, _.isEqual(config, current), config.clean); config.clean = next; break; } diff --git a/src/config/format.ts b/src/config/format.ts index b63debd..042d173 100644 --- a/src/config/format.ts +++ b/src/config/format.ts @@ -1,7 +1,7 @@ import fs from "fs"; import * as readLine from "node:readline/promises"; -const defaultDict: Record> = { +const defaultDict: Record> = { developmentToken: "Your development bot token (Used for testing in one server, rather than production)", developmentGuildID: "Your development guild ID", enableDevelopment: true, @@ -26,15 +26,16 @@ const defaultDict: Record `); } @@ -127,22 +131,9 @@ export default async function (walkthrough = false) { } if (walkthrough && !(json["mongoUrl"] ?? false)) json["mongoUrl"] = "mongodb://127.0.0.1:27017"; if (!((json["baseUrl"] as string | undefined) ?? "").endsWith("/")) (json["baseUrl"] as string) += "/"; - let hosts; - try { - hosts = fs.readFileSync("/etc/hosts", "utf8").toString().split("\n"); - } catch (e) { - return console.log( - "\x1b[31m⚠ No /etc/hosts found. Please ensure the file exists and is readable. (Windows is not supported, Mac and Linux users should not experience this error)" - ); - } - let localhost: string | undefined = hosts.find((line) => line.split(" ")[1] === "localhost"); - if (localhost) { - localhost = localhost.split(" ")[0]; - } else { - localhost = "127.0.0.1"; - } - json["mongoUrl"] = (json["mongoUrl"]! as string).replace("localhost", localhost!); - json["baseUrl"] = (json["baseUrl"]! as string).replace("localhost", localhost!); + const localhost = "127.0.0.1"; + json["mongoUrl"] = (json["mongoUrl"]! as string).replace("localhost", localhost); + json["baseUrl"] = (json["baseUrl"]! as string).replace("localhost", localhost); json["mongoOptions"] = { username: json["username"] as string, password: json["password"] as string, @@ -150,6 +141,11 @@ export default async function (walkthrough = false) { host: json["host"] as string, authSource: json["authSource"] as string }; + json["clamav"] = { + socket: json["clamAVSocket"] as string | undefined, + host: json["clamAVHost"] as string | undefined, + port: json["clamAVPort"] as number | undefined + }; fs.writeFileSync("./src/config/main.ts", "export default " + JSON.stringify(json, null, 4) + ";"); diff --git a/src/config/main.d.ts b/src/config/main.d.ts index 8953c52..6c610e0 100644 --- a/src/config/main.d.ts +++ b/src/config/main.d.ts @@ -18,12 +18,13 @@ declare const config: { authSource: string; }; baseUrl: string; - rapidApiKey: string; clamav: { socket?: string; host?: string; port?: number; }; + githubPAT: string; + suggestionChannel: string; }; export default config; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 3b0cd62..9d3dceb 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -4,8 +4,18 @@ import create from "../actions/tickets/create.js"; import close from "../actions/tickets/delete.js"; import createTranscript from "../premium/createTranscript.js"; -import type { ButtonInteraction, Interaction } from "discord.js"; -import type Discord from "discord.js"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + Interaction, + InteractionEditReplyOptions, + ModalBuilder, + ModalSubmitInteraction, + TextInputBuilder, + TextInputStyle +} from "discord.js"; import type { NucleusClient } from "../utils/client.js"; import EmojiEmbed from "../utils/generateEmojiEmbed.js"; @@ -14,6 +24,7 @@ import { callback as kickCallback, check as kickCheck } from "../commands/mod/ki import { callback as muteCallback, check as muteCheck } from "../commands/mod/mute.js"; import { callback as nicknameCallback, check as nicknameCheck } from "../commands/mod/nick.js"; import { callback as warnCallback, check as warnCheck } from "../commands/mod/warn.js"; +import client from "../utils/client.js"; export const event = "interactionCreate"; @@ -27,6 +38,13 @@ async function errorMessage(interaction: ButtonInteraction, message: string) { async function interactionCreate(interaction: Interaction) { if (interaction.isButton()) { + if (interaction.customId.endsWith(":Suggestion")) { + const value = + interaction.customId.startsWith("accept") || interaction.customId.startsWith("implement") + ? true + : false; + return await modifySuggestion(interaction, value); + } switch (interaction.customId) { case "rolemenu": { return await roleMenu(interaction); @@ -43,12 +61,6 @@ async function interactionCreate(interaction: Interaction) { case "createtranscript": { return await createTranscript(interaction); } - case "suggestionAccept": { - return await modifySuggestion(interaction, true); - } - case "suggestionDeny": { - return await modifySuggestion(interaction, false); - } } // Mod actions if (interaction.customId.startsWith("mod:")) { @@ -57,27 +69,27 @@ async function interactionCreate(interaction: Interaction) { const member = await interaction.guild?.members.fetch(memberId!); switch (action) { case "kick": { - const check = await kickCheck(interaction, false, member); + const check = kickCheck(interaction, false, member); if (check !== true) return await errorMessage(interaction, check!); return await kickCallback(interaction, member); } case "ban": { - const check = await banCheck(interaction, false, member); + const check = banCheck(interaction, false, member); if (check !== true) return await errorMessage(interaction, check!); return await banCallback(interaction, member); } case "mute": { - const check = await muteCheck(interaction, false, member); + const check = muteCheck(interaction, false, member); if (check !== true) return await errorMessage(interaction, check!); return await muteCallback(interaction, member); } case "nickname": { - const check = await nicknameCheck(interaction, false, member); + const check = nicknameCheck(interaction, false, member); if (check !== true) return await errorMessage(interaction, check || "Something went wrong"); return await nicknameCallback(interaction, member); } case "warn": { - const check = await warnCheck(interaction, false, member); + const check = warnCheck(interaction, false, member); if (check !== true) return await errorMessage(interaction, check!); return await warnCallback(interaction, member); } @@ -86,24 +98,138 @@ async function interactionCreate(interaction: Interaction) { } } -async function modifySuggestion(interaction: Discord.MessageComponentInteraction, accept: boolean) { - const message = await interaction.message; +const getReason = async (buttonInteraction: ButtonInteraction, prompt: string) => { + const modal = new ModalBuilder() + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder().setStyle(TextInputStyle.Paragraph).setLabel(prompt).setCustomId("typed") + ) + ) + .setTitle("Reason") + .setCustomId("modal"); + await buttonInteraction.showModal(modal); + let out: ModalSubmitInteraction; + try { + out = await buttonInteraction.awaitModalSubmit({ + filter: (i) => i.customId === "modal" && i.user.id === buttonInteraction.user.id, + time: 300000 + }); + } catch { + return null; + } + await out.deferUpdate(); + return out.fields.getTextInputValue("typed"); +}; + +async function modifySuggestion(interaction: ButtonInteraction, accept: boolean) { + const message = interaction.message; await message.fetch(); if (message.embeds.length === 0) return; - const embed = message.embeds[0]; - const newcolor = accept ? "Success" : "Danger"; - const footer = { - text: `Suggestion ${accept ? "accepted" : "denied"} by ${interaction.user.tag}`, - iconURL: interaction.user.displayAvatarURL() + const embed = message.embeds[0]!; + const issueNum = embed.footer!.text; + if (!issueNum) return; + const issue = { + owner: "ClicksMinutePer", + repo: "Nucleus", + issue_number: parseInt(issueNum) }; + let name = "Unknown"; + const components: InteractionEditReplyOptions["components"] = []; + switch (interaction.customId) { + case "accept:Suggestion": { + name = "Accepted"; + await interaction.deferUpdate(); + await client.GitHub.rest.issues.createComment({ + ...issue, + body: "Suggestion accepted by " + interaction.user.tag + }); + components.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("close:Suggestion") + .setLabel("Close") + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId("implemented:Suggestion") + .setLabel("Implemented") + .setStyle(ButtonStyle.Secondary) + ) + ); + break; + } + case "deny:Suggestion": { + name = "Denied"; + const reason = await getReason(interaction, "Reason for denial"); + await client.GitHub.rest.issues.createComment({ + ...issue, + body: "Suggestion denied by " + interaction.user.tag + " for reason:\n>" + reason + }); + await client.GitHub.rest.issues.update({ ...issue, state: "closed", state_reason: "not_planned" }); + // await client.GitHub.rest.issues.lock({...issue, lock_reason: "resolved"}) + components.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId("lock:Suggestion").setLabel("Lock").setStyle(ButtonStyle.Danger) + ) + ); + break; + } + case "close:Suggestion": { + name = "Closed"; + const reason = await getReason(interaction, "Reason for closing"); + await client.GitHub.rest.issues.createComment({ + ...issue, + body: "Suggestion closed by " + interaction.user.tag + " for reason:\n>" + reason + }); + await client.GitHub.rest.issues.update({ ...issue, state: "closed" }); + // await client.GitHub.rest.issues.lock({...issue}) + components.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId("lock:Suggestion").setLabel("Lock").setStyle(ButtonStyle.Danger) + ) + ); + break; + } + case "implement:Suggestion": { + name = "Implemented"; + await interaction.deferUpdate(); + await client.GitHub.rest.issues.createComment({ ...issue, body: "Suggestion implemented" }); + await client.GitHub.rest.issues.update({ ...issue, state: "closed", state_reason: "completed" }); + await client.GitHub.rest.issues.lock({ ...issue, lock_reason: "resolved" }); + break; + } + case "lock:Suggestion": { + name = "Locked"; + await interaction.deferUpdate(); + await client.GitHub.rest.issues.lock({ ...issue }); + break; + } + case "spam:Suggestion": { + name = "Marked as Spam"; + await interaction.deferUpdate(); + await client.GitHub.rest.issues.update({ ...issue, state: "closed", state_reason: "not_planned" }); + await client.GitHub.rest.issues.lock({ ...issue, lock_reason: "spam" }); + break; + } + } + + const newcolor = accept ? "Success" : "Danger"; + const newEmoji = accept ? "ICONS.ADD" : "ICONS.OPP.ADD"; const newEmbed = new EmojiEmbed() - .setTitle(embed!.title!) + .setEmoji(newEmoji) + .setTitle(embed!.title!.replace(/.+> /, "")) .setDescription(embed!.description!) - .setFooter(footer) - .setStatus(newcolor); + .setFields({ + name: name + " by", + value: interaction.user.tag + }) + .setStatus(newcolor) + .setFooter(embed!.footer); - await interaction.update({ embeds: [newEmbed], components: [] }); + await interaction.editReply({ + embeds: [newEmbed], + components: components + }); } export async function callback(_client: NucleusClient, interaction: Interaction) { diff --git a/src/utils/client.ts b/src/utils/client.ts index 43f8c5f..6899b90 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -6,6 +6,7 @@ import { Guilds, History, ModNotes, Premium, PerformanceTest, ScanCache, Transcr import EventScheduler from "../utils/eventScheduler.js"; import type { RoleMenuSchema } from "../actions/roleMenu.js"; import config from "../config/main.js"; +import { Octokit } from "octokit"; class NucleusClient extends Client { logger = Logger; @@ -24,6 +25,7 @@ class NucleusClient extends Client { scanCache: ScanCache; transcripts: Transcript; }; + GitHub = new Octokit({ auth: config.githubPAT }); preloadPage: Record = {}; // e.g. { channelID: { command: privacy, page: 3}} commands: Record< string, diff --git a/src/utils/confirmationMessage.ts b/src/utils/confirmationMessage.ts index 59befe6..0e4a9b4 100644 --- a/src/utils/confirmationMessage.ts +++ b/src/utils/confirmationMessage.ts @@ -42,7 +42,7 @@ class confirmationMessage { emoji: string; customId: string; modal: Discord.ModalBuilder; - value: string | undefined; + values: Record; }[] = []; constructor(interaction: CommandInteraction | ButtonInteraction) { @@ -106,9 +106,15 @@ class confirmationMessage { this.reason = reason; return this; } - addModal(buttonText: string, emoji: string, customId: string, current: string, modal: Discord.ModalBuilder) { + addModal( + buttonText: string, + emoji: string, + customId: string, + current: Record, + modal: Discord.ModalBuilder + ) { modal.setCustomId(customId); - this.modals.push({ buttonText, emoji, customId, modal, value: current }); + this.modals.push({ buttonText, emoji, customId, modal, values: current }); return this; } async send(editOnly?: boolean): Promise<{ @@ -121,7 +127,7 @@ class confirmationMessage { emoji: string; customId: string; modal: Discord.ModalBuilder; - value: string | undefined; + values: Record; }[]; }> { let cancelled = false; @@ -131,19 +137,19 @@ class confirmationMessage { while (!cancelled && success === undefined && !returnComponents && !newReason) { const fullComponents = [ - new Discord.ButtonBuilder() + new ButtonBuilder() .setCustomId("yes") .setLabel("Confirm") .setStyle(this.inverted ? ButtonStyle.Success : ButtonStyle.Danger) .setEmoji(getEmojiByName("CONTROL.TICK", "id")), - new Discord.ButtonBuilder() + new ButtonBuilder() .setCustomId("no") .setLabel("Cancel") - .setStyle(ButtonStyle.Secondary) + .setStyle(ButtonStyle.Danger) .setEmoji(getEmojiByName("CONTROL.CROSS", "id")) ]; Object.entries(this.customButtons).forEach(([k, v]) => { - const button = new Discord.ButtonBuilder() + const button = new ButtonBuilder() .setCustomId(k) .setLabel(v.title) .setStyle(v.active ? ButtonStyle.Success : ButtonStyle.Primary) @@ -153,7 +159,7 @@ class confirmationMessage { }); for (const modal of this.modals) { fullComponents.push( - new Discord.ButtonBuilder() + new ButtonBuilder() .setCustomId(modal.customId) .setLabel(modal.buttonText) .setStyle(ButtonStyle.Primary) @@ -163,7 +169,7 @@ class confirmationMessage { } if (this.reason !== null) fullComponents.push( - new Discord.ButtonBuilder() + new ButtonBuilder() .setCustomId("reason") .setLabel("Edit Reason") .setStyle(ButtonStyle.Primary) @@ -174,7 +180,7 @@ class confirmationMessage { for (let i = 0; i < fullComponents.length; i += 5) { components.push( new ActionRowBuilder< - | Discord.ButtonBuilder + | ButtonBuilder | Discord.StringSelectMenuBuilder | Discord.RoleSelectMenuBuilder | Discord.UserSelectMenuBuilder @@ -272,7 +278,7 @@ class confirmationMessage { .setEmoji(this.emoji) ], components: [ - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new ButtonBuilder() .setLabel("Back") .setEmoji(getEmojiByName("CONTROL.LEFT", "id")) @@ -322,7 +328,7 @@ class confirmationMessage { .setEmoji(this.emoji) ], components: [ - new ActionRowBuilder().addComponents( + new ActionRowBuilder().addComponents( new ButtonBuilder() .setLabel("Back") .setEmoji(getEmojiByName("CONTROL.LEFT", "id")) @@ -350,7 +356,9 @@ class confirmationMessage { continue; } if (out instanceof ModalSubmitInteraction) { - chosenModal!.value = out.fields.getTextInputValue("default"); + out.fields.fields.forEach((f, k) => { + chosenModal!.values[k] = f.value; + }); } returnComponents = true; continue;