diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ced7e20 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +.env +*.lock \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d781cf2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2022 | Naman Vrati [NamVr] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2538632 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# Yet Another Ticketing Bot +I made this bot primarily for BSMG (Beat Saber Modding Group), but if you find a use case for it, go ahead and use it :D. + +The bot is a fully customizable and dynamic ticketing bot capable of handling anything. + +# Running +You need to have a PostgreSQL database running and edit the .env according to your configuration. + +After you're done, just write + +```batch +yarn initiate +``` + +and your bot is running! \ No newline at end of file diff --git a/bot.js b/bot.js new file mode 100644 index 0000000..9ab38e9 --- /dev/null +++ b/bot.js @@ -0,0 +1,122 @@ +const fs = require("fs"); +const { Client, Collection, GatewayIntentBits, Partials } = require("discord.js"); +const { REST } = require("@discordjs/rest"); +const { Routes } = require("discord-api-types/v9"); +require("dotenv").config(); + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + ], + partials: [Partials.Channel], +}); + +const eventFiles = fs + .readdirSync("./events") + .filter((file) => file.endsWith(".js")); + +for (const file of eventFiles) { + const event = require(`./events/${file}`); + if (event.once) { + client.once(event.name, (...args) => event.execute(...args, client)); + } else { + client.on( + event.name, + async (...args) => await event.execute(...args, client) + ); + } +} + +client.slashCommands = new Collection(); +client.buttonCommands = new Collection(); +client.selectCommands = new Collection(); +client.modalCommands = new Collection(); +client.autocompleteInteractions = new Collection(); + +const slashCommands = fs.readdirSync("./interactions/slash"); + +for (const module of slashCommands) { + const commandFiles = fs + .readdirSync(`./interactions/slash/${module}`) + .filter((file) => file.endsWith(".js")); + + for (const commandFile of commandFiles) { + const command = require(`./interactions/slash/${module}/${commandFile}`); + client.slashCommands.set(command.data.name, command); + } +} + +const autocompleteInteractions = fs.readdirSync("./interactions/autocomplete"); + +for (const module of autocompleteInteractions) { + const files = fs + .readdirSync(`./interactions/autocomplete/${module}`) + .filter((file) => file.endsWith(".js")); + + for (const interactionFile of files) { + const interaction = require(`./interactions/autocomplete/${module}/${interactionFile}`); + client.autocompleteInteractions.set(interaction.name, interaction); + } +} + +const buttonCommands = fs.readdirSync("./interactions/buttons"); + +for (const module of buttonCommands) { + const commandFiles = fs + .readdirSync(`./interactions/buttons/${module}`) + .filter((file) => file.endsWith(".js")); + + for (const commandFile of commandFiles) { + const command = require(`./interactions/buttons/${module}/${commandFile}`); + client.buttonCommands.set(command.id, command); + } +} + +const modalCommands = fs.readdirSync("./interactions/modals"); + +for (const module of modalCommands) { + const commandFiles = fs + .readdirSync(`./interactions/modals/${module}`) + .filter((file) => file.endsWith(".js")); + + for (const commandFile of commandFiles) { + const command = require(`./interactions/modals/${module}/${commandFile}`); + client.modalCommands.set(command.id, command); + } +} + +const selectMenus = fs.readdirSync("./interactions/select-menus"); + +for (const module of selectMenus) { + const commandFiles = fs + .readdirSync(`./interactions/select-menus/${module}`) + .filter((file) => file.endsWith(".js")); + for (const commandFile of commandFiles) { + const command = require(`./interactions/select-menus/${module}/${commandFile}`); + client.selectCommands.set(command.id, command); + } +} + +const rest = new REST({ version: "9" }).setToken(process.env.TOKEN); + +const commandJsonData = [...Array.from(client.slashCommands.values()).map((c) => c.data.toJSON())]; + +(async () => { + try { + console.log("Started refreshing application (/) commands."); + + await rest.put( + Routes.applicationGuildCommands(process.env.CLIENT_ID, process.env.GUILD_ID), + { body: commandJsonData } + ); + + console.log("Successfully reloaded application (/) commands."); + } catch (error) { + console.error(error); + } +})(); + +client.login(process.env.TOKEN); \ No newline at end of file diff --git a/connectDb.js b/connectDb.js new file mode 100644 index 0000000..990a89e --- /dev/null +++ b/connectDb.js @@ -0,0 +1,19 @@ +const knex = require("knex"); + +const db = knex({ + client: "pg", + connection: { + user: process.env.DB_USER, + password: process.env.DB_PASS, + host: process.env.DB_HOST, + port: process.env.DB_PORT, + database: process.env.DB_NAME, + }, + version: 13, + pool: { + min: 2, + max: 10, + }, +}); + +module.exports = db; \ No newline at end of file diff --git a/events/autocompleteInteraction.js b/events/autocompleteInteraction.js new file mode 100644 index 0000000..6ef8525 --- /dev/null +++ b/events/autocompleteInteraction.js @@ -0,0 +1,22 @@ +module.exports = { + name: "interactionCreate", + async execute(interaction) { + const { client } = interaction; + + if (!interaction.isAutocomplete()) return; + + const request = client.autocompleteInteractions.get( + interaction.commandName + ); + + if (!request) return; + + try { + await request.execute(interaction); + } catch (err) { + console.error(err); + } + + return; + }, +}; diff --git a/events/buttonInteraction.js b/events/buttonInteraction.js new file mode 100644 index 0000000..57b0f2a --- /dev/null +++ b/events/buttonInteraction.js @@ -0,0 +1,70 @@ +const { ModalBuilder, TextInputStyle, TextInputBuilder, ActionRowBuilder } = require('discord.js'); +const db = require("../connectDb"); + +module.exports = { + name: "interactionCreate", + async execute(interaction) { + const { client } = interaction; + + if (!interaction.isButton()) return; + + const command = client.buttonCommands.get(interaction.customId); + + if (!command) { + if (isNaN(Number(interaction.customId))) { + await require("../messages/defaultButtonError").execute(interaction); + return; + } + + const button = await db("buttons") + .select("*") + .where("id", interaction.customId) + .first(); + + if (!button) { + await require("../messages/defaultButtonError").execute(interaction); + return; + } + + const questions = [] + if (button.q1) questions.push({ question: button.q1, type: button.t1, number: 1}); + if (button.q2) questions.push({ question: button.q2, type: button.t2, number: 2}); + if (button.q3) questions.push({ question: button.q3, type: button.t3, number: 3}); + if (button.q4) questions.push({ question: button.q4, type: button.t4, number: 4}); + if (button.q5) questions.push({ question: button.q5, type: button.t5, number: 5}); + + const modal = new ModalBuilder() + .setCustomId(interaction.customId) + .setTitle(button.label) + .addComponents( + questions.map((question) => { + const row = new ActionRowBuilder(); + const input = new TextInputBuilder() + .setCustomId(question.number.toString()) + .setPlaceholder(question.question) + .setLabel(question.question) + .setStyle(TextInputStyle[question.type === true ? "Short" : "Paragraph"]) + .setRequired(true); + + row.addComponents(input); + return row; + }) + ); + + await interaction.showModal(modal); + return; + } + + try { + await command.execute(interaction); + return; + } catch (err) { + console.error(err); + await interaction.reply({ + content: "There was an issue while executing that button!", + ephemeral: true, + }); + return; + } + }, +}; diff --git a/events/modalInteraction.js b/events/modalInteraction.js new file mode 100644 index 0000000..6eff983 --- /dev/null +++ b/events/modalInteraction.js @@ -0,0 +1,111 @@ +const { ChannelType, EmbedBuilder } = require("discord.js"); +const db = require("../connectDb"); + +module.exports = { + name: "interactionCreate", + async execute(interaction) { + const client = interaction.client; + + if (!interaction.isModalSubmit()) return; + + const command = client.modalCommands.get(interaction.customId); + + if (!command) { + if (isNaN(Number(interaction.customId))) { + await require("../messages/defaultModalError").execute(interaction); + return; + } + + const category = await db("buttons") + .select("*") + .where("id", interaction.customId) + .first(); + + if (!category) { + await require("../messages/defaultModalError").execute(interaction); + return; + } + + const questions = []; + const formattedQuestions = []; + if (category.q1) questions.push({ question: category.q1, type: category.t1, number: 1 }); + if (category.q2) questions.push({ question: category.q2, type: category.t2, number: 2 }); + if (category.q3) questions.push({ question: category.q3, type: category.t3, number: 3 }); + if (category.q4) questions.push({ question: category.q4, type: category.t4, number: 4 }); + if (category.q5) questions.push({ question: category.q5, type: category.t5, number: 5 }); + + const lastTicket = await db("tickets") + .select("*") + .orderBy("id", "desc") + .first(); + + const ticketNumber = lastTicket ? lastTicket.id + 1 : 1; + + const channel = await interaction.guild.channels.create({ + name: `ticket-${ticketNumber}`, + type: ChannelType.GuildText, + parent: category.category_id, + }); + + channel.permissionOverwrites.edit(interaction.user.id, { + ViewChannel: true, + SendMessages: true, + }) + + await db("tickets").insert({ + user_id: interaction.user.id, + ticket_id: category.id, + category_id: category.category_id, + channel_id: channel.id, + status: "open", + log_channel_id: category.log_channel_id + }); + + for (const question of questions) { + const response = interaction.fields.getTextInputValue(question.number.toString()); + formattedQuestions.push({ question: question.question, response: response }); + } + + const ticketEmbed = new EmbedBuilder() + .setTitle(`Ticket #${ticketNumber}`) + .addFields( + formattedQuestions.map((question) => ({ + name: question.question, + value: question.response, + })) + ) + .setColor("Random") + .setAuthor({ name: interaction.user.tag, iconURL: interaction.user.displayAvatarURL() }) + .setTimestamp(); + + channel.send({ embeds: [ticketEmbed] }); + + const logEmbed = new EmbedBuilder() + .setTitle(`Ticket #${ticketNumber} created`) + .setDescription(`Ticket created by ${interaction.user}`) + .setColor("Green") + .setAuthor({ name: interaction.user.tag, iconURL: interaction.user.displayAvatarURL() }) + .addFields( + { name: "Ticket type", value: category.label, inline: true }, + ) + + const logChannel = await interaction.guild.channels.fetch(category.log_channel_id); + + logChannel.send({ embeds: [logEmbed] }); + interaction.reply({ content: `Ticket created in <#${channel.id}>`, ephemeral: true }); + return; + } + + try { + await command.execute(interaction); + return; + } catch (err) { + console.error(err); + await interaction.reply({ + content: "There was an issue while understanding this modal!", + ephemeral: true, + }); + return; + } + }, +}; diff --git a/events/onReady.js b/events/onReady.js new file mode 100644 index 0000000..43e7f27 --- /dev/null +++ b/events/onReady.js @@ -0,0 +1,62 @@ +const db = require("../connectDb"); +const discordTranscripts = require("discord-html-transcripts"); + +module.exports = { + name: "ready", + once: true, + execute(client) { + console.log(`Ready! Logged in as ${client.user.tag}`); + + setInterval(() => { + const tickets = db("tickets") + .select("*") + .where("status", "open") + .andWhere("close_requested_at", "!=", null); + + tickets.forEach(async ticket => { + const closeRequestedAt = new Date(ticket.close_requested_at); + const timeout = ticket.timeout * 24 * 60 * 60 * 1000; + + if (Date.now() - closeRequestedAt > timeout) { + const channel = client.channels.cache.get(ticket.channel_id); + const transcript = await discordTranscripts.createTranscript(channel, { + limit: -1, + filename: `transcript-${channel.name}.html`, + saveImages: false, + poweredBy: false, + ssr: false + }); + + const category = await db("buttons") + .select("label") + .where("id", ticket.ticket_id) + .first(); + + const logChannel = interaction.guild.channels.cache.get(ticket.log_channel_id); + + const embed = new EmbedBuilder() + .setTitle(`Ticket #${ticket.id} closed`) + .setDescription(`Ticket closed by \`Autobot due to inactivity\`.`) + .addFields( + { name: "Ticket type", value: `${category.label}` }, + { name: "Ticket owner", value: `ID: ${ticket.user_id}` } + ) + .setColor("Red") + .setFooter({ text: `Transcript is attached below this message` }) + + logChannel.send({ embeds: [embed] }); + logChannel.send({ files: [transcript] }); + + interaction.followUp({ content: `Closing this ticket due to inactivity...` }); + + setTimeout(async () => { + await channel.delete(); + await db("tickets") + .where("channel_id", channel.id) + .update({ status: "closed", closed_by: "Autobot" }); + }, 5000); + } + }) + }, 1000 * 60 * 60); + }, +}; diff --git a/events/selectInteraction.js b/events/selectInteraction.js new file mode 100644 index 0000000..376607e --- /dev/null +++ b/events/selectInteraction.js @@ -0,0 +1,27 @@ +module.exports = { + name: "interactionCreate", + async execute(interaction) { + const { client } = interaction; + + if (!interaction.isAnySelectMenu()) return; + + const command = client.selectCommands.get(interaction.customId); + + if (!command) { + await require("../messages/defaultSelectError").execute(interaction); + return; + } + + try { + await command.execute(interaction); + return; + } catch (err) { + console.error(err); + await interaction.reply({ + content: "There was an issue while executing that select menu option!", + ephemeral: true, + }); + return; + } + }, +}; diff --git a/events/slashCreate.js b/events/slashCreate.js new file mode 100644 index 0000000..a8c64e8 --- /dev/null +++ b/events/slashCreate.js @@ -0,0 +1,22 @@ +module.exports = { + name: "interactionCreate", + async execute(interaction) { + const { client } = interaction; + + if (!interaction.isChatInputCommand()) return; + + const command = client.slashCommands.get(interaction.commandName); + + if (!command) return; + + try { + await command.execute(interaction); + } catch (err) { + console.error(err); + await interaction.reply({ + content: "There was an issue while executing that command!", + ephemeral: true, + }); + } + }, +}; diff --git a/example.env b/example.env new file mode 100644 index 0000000..a404468 --- /dev/null +++ b/example.env @@ -0,0 +1,9 @@ +TOKEN=BOT_TOKEN +GUILD_ID=SERVER_ID +CLIENT_ID=BOT_ID +MANAGER_ROLE_ID=ROLE_ID +DB_USER=postgres +DB_PASS=postgrespw +DB_HOST=localhost +DB_PORT=32768 +DB_NAME=postgres \ No newline at end of file diff --git a/interactions/autocomplete/tickets/ticket_tool.js b/interactions/autocomplete/tickets/ticket_tool.js new file mode 100644 index 0000000..9bc0696 --- /dev/null +++ b/interactions/autocomplete/tickets/ticket_tool.js @@ -0,0 +1,53 @@ +const db = require("../../../connectDb"); + +module.exports = { + name: "ticket", + async execute(interaction) { + const focusedValue = interaction.options.getFocused(true); + + if (focusedValue.name === "name" || focusedValue.name === "category") { + + const options = await db("buttons") + .select("label") + + const choices = options.map((option) => option.label); + + const filtered = choices.filter((choice) => + choice.toLowerCase().includes(focusedValue.value.toLowerCase()) + ); + + await interaction.respond( + filtered.map((choice) => ({ name: choice, value: choice })) + ); + + return; + } + + else { + const label = interaction.options.getString("category"); + + const options = await db("buttons") + .select("q1", "q2", "q3", "q4", "q5") + .where("label", label) + .first(); + + const choices = []; + + for (const [key, value] of Object.entries(options)) { + if (value) { + choices.push(value.substring(0, 100)); + } + } + + const filtered = choices.filter((choice) => + choice.toLowerCase().includes(focusedValue.value.toLowerCase()) + ); + + await interaction.respond( + filtered.map((choice) => ({ name: choice, value: choice })) + ); + + return; + } + }, +}; diff --git a/interactions/buttons/modal/acceptMessage.js b/interactions/buttons/modal/acceptMessage.js new file mode 100644 index 0000000..46b60c0 --- /dev/null +++ b/interactions/buttons/modal/acceptMessage.js @@ -0,0 +1,21 @@ +const { EmbedBuilder } = require('discord.js'); + +module.exports = { + id: "accept_message", + async execute(interaction) { + const channel = interaction.message.embeds[0].footer.text; + const message = interaction.message.embeds[0].description; + const title = interaction.message.embeds[0].title; + const color = interaction.message.embeds[0].color; + const actionRow = interaction.message.components[0]; + + const embed = new EmbedBuilder() + .setTitle(title) + .setDescription(message) + .setColor(color) + + const channelSend = interaction.client.channels.cache.get(channel); + await channelSend.send({ embeds: [embed], components: [actionRow] }); + interaction.update({ content: `Message sent in <#${channel}>`, components: [], embeds: [] }); + } +} \ No newline at end of file diff --git a/interactions/buttons/modal/deleteMessage.js b/interactions/buttons/modal/deleteMessage.js new file mode 100644 index 0000000..3ebda9b --- /dev/null +++ b/interactions/buttons/modal/deleteMessage.js @@ -0,0 +1,6 @@ +module.exports = { + id: "delete_message", + async execute(interaction) { + await interaction.message.delete(); + } +} \ No newline at end of file diff --git a/interactions/buttons/modal/editMessage.js b/interactions/buttons/modal/editMessage.js new file mode 100644 index 0000000..69bf560 --- /dev/null +++ b/interactions/buttons/modal/editMessage.js @@ -0,0 +1,60 @@ +const { ModalBuilder, TextInputStyle, TextInputBuilder, ActionRowBuilder } = require('discord.js'); + +module.exports = { + id: "edit_message", + async execute(interaction) { + const channel = interaction.message.embeds[0].footer.text; + const message = interaction.message.embeds[0].description; + const title = interaction.message.embeds[0].title; + let color = interaction.message.embeds[0].color; + + color = color.toString(16); + + const modal = new ModalBuilder() + .setTitle('Post a message') + .setCustomId('update_message') + .addComponents( + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setValue(message) + .setCustomId('message') + .setLabel('The message. Markdown supported.') + .setStyle(TextInputStyle.Paragraph) + .setMinLength(10) + .setMaxLength(2000) + .setRequired(true) + ), + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setValue(title) + .setCustomId('embed_title') + .setLabel('The title of the embed.') + .setStyle(TextInputStyle.Short) + .setMinLength(1) + .setMaxLength(256) + ), + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setValue(`#${color}`) + .setCustomId('embed_color') + .setLabel('The color of the embed. (hex with #)') + .setStyle(TextInputStyle.Short) + .setMinLength(7) + .setMaxLength(7) + ), + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setValue(channel) + .setCustomId('channel_id') + .setLabel('DO NOT TOUCH!!!!') + .setStyle(TextInputStyle.Short) + ) + ) + + await interaction.showModal(modal); + } +} \ No newline at end of file diff --git a/interactions/buttons/operation/cancel.js b/interactions/buttons/operation/cancel.js new file mode 100644 index 0000000..7929f8c --- /dev/null +++ b/interactions/buttons/operation/cancel.js @@ -0,0 +1,17 @@ +const { EmbedBuilder } = require("discord.js"); +const db = require("../../../connectDb"); + +module.exports = { + id: "cancel_close", + async execute(interaction) { + const embed = new EmbedBuilder() + .setDescription(`Close request cancelled by ${interaction.user}.`) + .setColor("Random") + + await db("tickets") + .update({ close_requested_at: null }) + .where("channel_id", interaction.channel.id) + + await interaction.update({ embeds: [embed], components: [] }); + } +} \ No newline at end of file diff --git a/interactions/buttons/operation/confirm.js b/interactions/buttons/operation/confirm.js new file mode 100644 index 0000000..8f5fa27 --- /dev/null +++ b/interactions/buttons/operation/confirm.js @@ -0,0 +1,75 @@ +const { EmbedBuilder } = require("discord.js"); +const db = require("../../../connectDb"); +const discordTranscripts = require("discord-html-transcripts"); + +module.exports = { + id: "confirm_close", + async execute(interaction) { + await interaction.deferReply(); + const channel = interaction.channel; + const footer = interaction.message.embeds[0].footer.text; + + if (footer.startsWith("Wait for")) { + const settings = await db("settings") + .select("roles") + .first(); + + const member = interaction.guild.members.cache.get(interaction.user.id); + + const hasRole = member.roles.cache.some((role) => settings.roles.includes(role.id)); + + if (!hasRole) { + return interaction.followUp({ content: "You don't have permission to close this ticket.", ephemeral: true }); + } + } + + else { + if (interaction.user.id !== footer.split(" | ")[0]) { + return interaction.followUp({ content: "You don't have permission to close this ticket.", ephemeral: true }); + } + } + + const transcript = await discordTranscripts.createTranscript(channel, { + limit: -1, + filename: `transcript-${channel.name}.html`, + saveImages: false, + poweredBy: false, + ssr: false + }); + + const ticket = await db("tickets") + .select("*") + .where("channel_id", channel.id) + .first(); + + const category = await db("buttons") + .select("label") + .where("id", ticket.ticket_id) + .first(); + + const logChannel = interaction.guild.channels.cache.get(ticket.log_channel_id); + + const embed = new EmbedBuilder() + .setTitle(`Ticket #${ticket.id} closed`) + .setAuthor({ name: interaction.user.tag, iconURL: interaction.user.displayAvatarURL() }) + .setDescription(`Ticket closed by ${interaction.user}.`) + .addFields( + { name: "Ticket type", value: `${category.label}` }, + { name: "Ticket owner", value: `ID: ${ticket.user_id}` } + ) + .setColor("Red") + .setFooter({ text: `Transcript is attached below this message` }) + + logChannel.send({ embeds: [embed] }); + logChannel.send({ files: [transcript] }); + + interaction.followUp({ content: `Closing this ticket in 5 seconds...` }); + + setTimeout(async () => { + await channel.delete(); + await db("tickets") + .where("channel_id", channel.id) + .update({ status: "closed", closed_by: footer.startsWith("Wait for") ? interaction.user.id : footer.split(" | ")[1] }); + }, 5000); + } +} \ No newline at end of file diff --git a/interactions/modals/messages/post.js b/interactions/modals/messages/post.js new file mode 100644 index 0000000..883b707 --- /dev/null +++ b/interactions/modals/messages/post.js @@ -0,0 +1,51 @@ +const { EmbedBuilder, ActionRowBuilder, StringSelectMenuBuilder, ButtonBuilder, ButtonStyle } = require("discord.js"); +const db = require("../../../connectDb"); + +module.exports = { + id: "post_message", + async execute(interaction) { + const message = interaction.fields.getTextInputValue("message"); + const title = interaction.fields.getTextInputValue("embed_title"); + const color = interaction.fields.getTextInputValue("embed_color"); + const channel = interaction.fields.getTextInputValue("channel_id"); + + const embed = new EmbedBuilder() + .setTitle(title) + .setDescription(message) + .setColor(color) + .setFooter({ text: `${channel}` }) + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId("edit_message") + .setLabel("Edit Message") + .setStyle(ButtonStyle.Primary) + .setEmoji("📝") + ) + .addComponents( + new ButtonBuilder() + .setCustomId("delete_message") + .setLabel("Delete Message") + .setStyle(ButtonStyle.Danger) + .setEmoji("🗑️") + ) + + const tickets = await db("buttons") + .select("label") + + const options = tickets.map((ticket) => ({ label: ticket.label, value: ticket.label })) + + const row2 = new ActionRowBuilder() + .addComponents( + new StringSelectMenuBuilder() + .setCustomId("tickets") + .addOptions(options) + .setMinValues(1) + .setMaxValues(tickets.length < 5 ? tickets.length : 5) + .setPlaceholder("Select ticket categories") + ) + + await interaction.reply({ embeds: [embed], components: [row, row2] }); + } +} \ No newline at end of file diff --git a/interactions/modals/messages/update.js b/interactions/modals/messages/update.js new file mode 100644 index 0000000..87eadfa --- /dev/null +++ b/interactions/modals/messages/update.js @@ -0,0 +1,49 @@ +const { EmbedBuilder, ActionRowBuilder, StringSelectMenuBuilder, ButtonBuilder, ButtonStyle } = require("discord.js"); +const db = require("../../../connectDb"); + +module.exports = { + id: "update_message", + async execute(interaction) { + const message = interaction.fields.getTextInputValue("message"); + const title = interaction.fields.getTextInputValue("embed_title"); + const color = interaction.fields.getTextInputValue("embed_color"); + const channel = interaction.fields.getTextInputValue("channel_id"); + + const embed = new EmbedBuilder() + .setTitle(title) + .setDescription(message) + .setColor(color) + .setFooter({ text: `${channel}` }) + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId("edit_message") + .setLabel("Edit Message") + .setStyle(ButtonStyle.Primary) + ) + .addComponents( + new ButtonBuilder() + .setCustomId("delete_message") + .setLabel("Delete Message") + .setStyle(ButtonStyle.Danger) + ) + + const tickets = await db("buttons") + .select("label") + + const options = tickets.map((ticket) => ({ label: ticket.label, value: ticket.label })) + + const row2 = new ActionRowBuilder() + .addComponents( + new StringSelectMenuBuilder() + .setCustomId("tickets") + .addOptions(options) + .setMinValues(1) + .setMaxValues(tickets.length < 5 ? tickets.length : 5) + .setPlaceholder("Select ticket categories") + ) + + await interaction.update({ embeds: [embed], components: [row, row2] }); + } +} \ No newline at end of file diff --git a/interactions/select-menus/modal/buttonSelect.js b/interactions/select-menus/modal/buttonSelect.js new file mode 100644 index 0000000..d779e01 --- /dev/null +++ b/interactions/select-menus/modal/buttonSelect.js @@ -0,0 +1,45 @@ +const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require("discord.js"); +const db = require("../../../connectDb"); + +module.exports = { + id: "tickets", + async execute(interaction) { + const values = interaction.values; + const array = values.map((value) => `${value}`); + + const row = new ActionRowBuilder() + + for (const value of array) { + const options = await db("buttons") + .select("style", "label", "emoji", "id") + .where("label", value) + .first(); + + const button = new ButtonBuilder() + .setCustomId(options.id.toString()) + .setLabel(options.label) + .setStyle(ButtonStyle[options.style]) + options.emoji ? button.setEmoji(options.emoji) : null; + + row.addComponents(button); + } + + const row2 = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId("accept_message") + .setLabel("Accept Message") + .setStyle(ButtonStyle.Success) + .setEmoji("✅") + ) + .addComponents( + new ButtonBuilder() + .setCustomId("delete_message") + .setLabel("Delete Message") + .setStyle(ButtonStyle.Danger) + .setEmoji("🗑️") + ) + + await interaction.update({ components: [row, row2] }); + } +} \ No newline at end of file diff --git a/interactions/slash/misc/roles.js b/interactions/slash/misc/roles.js new file mode 100644 index 0000000..971aec4 --- /dev/null +++ b/interactions/slash/misc/roles.js @@ -0,0 +1,77 @@ +const { SlashCommandBuilder } = require('discord.js'); +const db = require('../../../connectDb'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('role') + .setDescription('Manage role access.') + .addSubcommand(subcommand => + subcommand + .setName('add') + .setDescription('Add a role.') + .addRoleOption(option => + option.setName('role') + .setDescription('Role to add.') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('remove') + .setDescription('Remove a role.') + .addRoleOption(option => + option.setName('role') + .setDescription('Role to remove.') + .setRequired(true) + ) + ), + async execute(interaction) { + if (process.env.MANAGER_ROLE_ID && !interaction.member.roles.cache.has(process.env.MANAGER_ROLE_ID)) { + return await interaction.reply({ content: 'You do not have permission to use this command.', ephemeral: true }); + } + + const role = interaction.options.getRole('role'); + + const roles = await db("settings") + .select("roles") + .first() + + if (interaction.options.getSubcommand() === "add") { + if (roles === undefined) { + await db("settings") + .insert({ roles: [role.id] }) + + return await interaction.reply({ content: "Role added!", ephemeral: true }) + } + + if (roles.roles.includes(role.id)) { + return interaction.reply({ content: "This role is already added.", ephemeral: true }) + } + + roles.roles.push(role.id) + + await db("settings") + .update({ roles: roles.roles }) + + await interaction.reply({ content: "Role added!", ephemeral: true }) + } + + else { + if (roles === undefined) { + return interaction.reply({ content: "There are no roles added.", ephemeral: true }) + } + + if (!roles.roles.includes(role.id)) { + return interaction.reply({ content: "This role is not added.", ephemeral: true }) + } + + const index = roles.roles.indexOf(role.id) + roles.roles.splice(index, 1) + + await db("settings") + .update({ roles: roles.roles }) + + await interaction.reply({ content: "Role removed!", ephemeral: true }) + } + } +} \ No newline at end of file diff --git a/interactions/slash/misc/stats.js b/interactions/slash/misc/stats.js new file mode 100644 index 0000000..109e289 --- /dev/null +++ b/interactions/slash/misc/stats.js @@ -0,0 +1,77 @@ +const { SlashCommandBuilder, PermissionFlagsBits } = require('discord.js'); +const db = require('../../../connectDb'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('stats') + .setDescription('Ticket stats.') + .addSubcommand( + subcommand => + subcommand + .setName('general') + .setDescription('General ticket stats.') + ) + .addSubcommand( + subcommand => + subcommand + .setName('category') + .setDescription('Category ticket stats.') + .addStringOption( + option => + option + .setName('category') + .setDescription('Category to get stats from.') + .setRequired(true) + ) + ) + .addSubcommand( + subcommand => + subcommand + .setName('user') + .setDescription('User ticket stats.') + .addUserOption( + option => + option + .setName('user') + .setDescription('User to get stats from.') + .setRequired(true) + ) + ) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === "general") { + const tickets = await db("tickets") + .select("*") + + const open = tickets.filter(ticket => ticket.status === "open").length + const closed = tickets.filter(ticket => ticket.status === "closed").length + + return interaction.reply({ content: `Open tickets: ${open}\nClosed tickets: ${closed}\nAll tickets: ${open + closed}` }) + } + else if (subcommand === "category") { + const category = interaction.options.getString("category"); + + const tickets = await db("tickets") + .select("*") + + const categoryTickets = tickets.filter(ticket => ticket.ticket_id === category) + + const open = categoryTickets.filter(ticket => ticket.status === "open").length + const closed = categoryTickets.filter(ticket => ticket.status === "closed").length + + return interaction.reply({ content: `Open tickets: ${open}\nClosed tickets: ${closed}\nAll tickets: ${open + closed}` }) + } + else { + const user = interaction.options.getUser("user"); + + const tickets = await db("tickets") + .select("*") + + const userTickets = tickets.filter(ticket => ticket.closed_by === user.id).length + + return interaction.reply({ content: `This user has closed ${userTickets} tickets` }) + } + } +} \ No newline at end of file diff --git a/interactions/slash/misc/timeout.js b/interactions/slash/misc/timeout.js new file mode 100644 index 0000000..94f556c --- /dev/null +++ b/interactions/slash/misc/timeout.js @@ -0,0 +1,29 @@ +const { SlashCommandBuilder } = require('discord.js'); +const db = require('../../../connectDb'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('ticket_timeout') + .setDescription('Set the default ticket timeout after a close request.') + .addIntegerOption(option => + option.setName('timeout') + .setDescription('Timeout in days.') + .setRequired(true) + ), + async execute(interaction) { + if (process.env.MANAGER_ROLE_ID && !interaction.member.roles.cache.has(process.env.MANAGER_ROLE_ID)) { + return await interaction.reply({ content: 'You do not have permission to use this command.', ephemeral: true }); + } + + const settings = await db("settings") + .select("*") + .first() + + if (settings === undefined) { + await db("settings") + .insert({ ticket_timeout: interaction.options.getInteger('timeout') }) + + return await interaction.reply({ content: "Timeout set!", ephemeral: true }) + } + } +} \ No newline at end of file diff --git a/interactions/slash/operation/close.js b/interactions/slash/operation/close.js new file mode 100644 index 0000000..e5a9a6a --- /dev/null +++ b/interactions/slash/operation/close.js @@ -0,0 +1,57 @@ +const { SlashCommandBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ActionRowBuilder } = require("discord.js"); +const db = require("../../../connectDb"); + +module.exports = { + data: new SlashCommandBuilder() + .setName('close') + .setDescription('Close a ticket.'), + async execute(interaction) { + const channel = interaction.channel; + + if (!channel.name.startsWith("ticket-")) { + return interaction.reply({ content: "This is not a ticket channel.", ephemeral: true }) + } + + let embed = new EmbedBuilder() + .setDescription(`${interaction.user} wants to close the ticket.`) + .setColor("Random") + + const ticket = await db("tickets") + .select("*") + .where("channel_id", channel.id) + .first(); + + if (interaction.user.id === ticket.user_id) { + embed.setFooter({ text: "Wait for a staff member to close the ticket." }) + } + + else { + embed.setFooter({ text: `${ticket.user_id} | ${interaction.user.id}` }) + } + + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId("confirm_close") + .setLabel("Confirm") + .setStyle(ButtonStyle.Success) + .setEmoji("✅") + ) + .addComponents( + new ButtonBuilder() + .setCustomId("cancel_close") + .setLabel("Cancel") + .setStyle(ButtonStyle.Danger) + .setEmoji("✖️") + ) + + await db("tickets") + .where("channel_id", channel.id) + .update({ close_requested_at: new Date() }) + + await interaction.reply({ embeds: [embed], components: [row] }); + if (interaction.user.id !== ticket.user_id) { + await interaction.channel.send({ content: `<@${ticket.user_id}>, Please confirm the closing of this ticket.` }) + } + } +} \ No newline at end of file diff --git a/interactions/slash/operation/forceClose.js b/interactions/slash/operation/forceClose.js new file mode 100644 index 0000000..663d74c --- /dev/null +++ b/interactions/slash/operation/forceClose.js @@ -0,0 +1,73 @@ +const { SlashCommandBuilder, EmbedBuilder } = require("discord.js"); +const db = require("../../../connectDb"); +const discordTranscripts = require("discord-html-transcripts"); + +module.exports = { + data: new SlashCommandBuilder() + .setName('force_close') + .setDescription('Force close a ticket.'), + async execute(interaction) { + const channel = interaction.channel; + const settings = await db("settings") + .select("*") + .first(); + + if (!channel.name.startsWith("ticket-")) { + return interaction.reply({ content: "This is not a ticket channel.", ephemeral: true }) + } + + const member = interaction.guild.members.cache.get(interaction.user.id); + + const hasRole = member.roles.cache.some((role) => settings.roles.includes(role.id)); + + if (!hasRole) { + return interaction.reply({ content: "You don't have permission to force close this ticket.", ephemeral: true }); + } + + await interaction.deferReply(); + + const transcript = await discordTranscripts.createTranscript(channel, { + limit: -1, + filename: `transcript-${channel.name}.html`, + saveImages: false, + poweredBy: false, + ssr: false + }); + + const ticket = await db("tickets") + .select("*") + .where("channel_id", channel.id) + .first(); + + const category = await db("buttons") + .select("label") + .where("id", ticket.ticket_id) + .first(); + + const logChannel = interaction.guild.channels.cache.get(ticket.log_channel_id); + + const embed = new EmbedBuilder() + .setTitle(`Ticket #${ticket.id} closed`) + .setAuthor({ name: interaction.user.tag, iconURL: interaction.user.displayAvatarURL() }) + .setDescription(`Ticket closed by ${interaction.user}.`) + .addFields( + { name: "Ticket type", value: `${category.label}` }, + { name: "Ticket owner", value: `ID: ${ticket.user_id}` } + ) + .setColor("Red") + .setFooter({ text: `Transcript is attached below this message` }) + + logChannel.send({ embeds: [embed] }); + logChannel.send({ files: [transcript] }); + + interaction.followUp({ content: `Closing this ticket in 5 seconds...` }); + + setTimeout(async () => { + await channel.delete(); + await db("tickets") + .where("channel_id", channel.id) + .update({ status: "closed", closed_by: interaction.user.id }); + }, 5000); + + } +} \ No newline at end of file diff --git a/interactions/slash/tickets/post.js b/interactions/slash/tickets/post.js new file mode 100644 index 0000000..c543c59 --- /dev/null +++ b/interactions/slash/tickets/post.js @@ -0,0 +1,62 @@ +const { SlashCommandBuilder, ModalBuilder, TextInputStyle, TextInputBuilder, ActionRowBuilder } = require('discord.js'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('post') + .setDescription('Post a message for tickets.') + .addChannelOption(option => + option.setName('channel') + .setDescription('Channel to post the message in.') + .setRequired(true) + ), + async execute(interaction) { + const channel = interaction.options.getChannel('channel'); + + const modal = new ModalBuilder() + .setTitle('Post a message') + .setCustomId('post_message') + .addComponents( + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setPlaceholder('Message') + .setCustomId('message') + .setLabel('The message. Markdown supported.') + .setStyle(TextInputStyle.Paragraph) + .setMinLength(10) + .setMaxLength(2000) + .setRequired(true) + ), + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setPlaceholder('Embed Title') + .setCustomId('embed_title') + .setLabel('The title of the embed.') + .setStyle(TextInputStyle.Short) + .setMinLength(1) + .setMaxLength(256) + ), + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setPlaceholder('Embed Color') + .setCustomId('embed_color') + .setLabel('The color of the embed. (hex with #)') + .setStyle(TextInputStyle.Short) + .setMinLength(7) + .setMaxLength(7) + ), + new ActionRowBuilder() + .addComponents( + new TextInputBuilder() + .setValue(channel.id) + .setCustomId('channel_id') + .setLabel('DO NOT TOUCH!!!!') + .setStyle(TextInputStyle.Short) + ) + ) + + await interaction.showModal(modal); + } +} \ No newline at end of file diff --git a/interactions/slash/tickets/ticket_tool.js b/interactions/slash/tickets/ticket_tool.js new file mode 100644 index 0000000..8c1b8a8 --- /dev/null +++ b/interactions/slash/tickets/ticket_tool.js @@ -0,0 +1,263 @@ +const { SlashCommandBuilder } = require('discord.js'); +const db = require('../../../connectDb'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('ticket') + .setDescription('Create/edit ticket categories.') + .addSubcommand(subcommand => + subcommand + .setName('create') + .setDescription('Create a ticket category.') + .addStringOption(option => + option.setName('name') + .setDescription('Name of the ticket category.') + .setRequired(true) + ) + .addStringOption(option => + option.setName('color') + .setDescription('Color of the ticket category.') + .setRequired(true) + .addChoices( + { name: 'Blue', value: 'Primary' }, + { name: 'Gray', value: 'Secondary' }, + { name: 'Green', value: 'Success' }, + { name: 'Red', value: 'Danger' } + ) + ) + .addChannelOption(option => + option.setName('log_channel') + .setDescription('Channel to log ticket creations.') + .setRequired(true) + ) + .addChannelOption(option => + option.setName('category') + .setDescription('Category to create the ticket in.') + .setRequired(true) + ) + .addIntegerOption(option => + option.setName('timeout') + .setDescription('Timeout in days.') + .setRequired(true) + ) + .addStringOption(option => + option.setName('emoji') + .setDescription('Emoji to use for the ticket category.') + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('edit') + .setDescription('Edit a ticket category.') + .addStringOption(option => + option.setName('name') + .setDescription('Name of the ticket category.') + .setAutocomplete(true) + .setRequired(true) + ) + .addStringOption(option => + option.setName('color') + .setDescription('Color of the ticket category.') + .addChoices( + { name: 'Blue', value: 'Primary' }, + { name: 'Gray', value: 'Secondary' }, + { name: 'Green', value: 'Success' }, + { name: 'Red', value: 'Danger' } + ) + ) + .addChannelOption(option => + option.setName('log_channel') + .setDescription('Channel to log ticket creations.') + ) + .addStringOption(option => + option.setName('emoji') + .setDescription('Emoji to use for the ticket category.') + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('delete') + .setDescription('Delete a ticket category.') + .addStringOption(option => + option.setName('name') + .setDescription('Name of the ticket category.') + .setAutocomplete(true) + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('add_question') + .setDescription('Add a question to a ticket category.') + .addStringOption(option => + option.setName('category') + .setDescription('Name of the ticket category.') + .setAutocomplete(true) + .setRequired(true) + ) + .addStringOption(option => + option.setName('question') + .setDescription('Question to add.') + .setRequired(true) + ) + .addBooleanOption(option => + option.setName('short') + .setDescription('Whether the answer should be short or long form.') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('remove_question') + .setDescription('Remove a question from a ticket category.') + .addStringOption(option => + option.setName('category') + .setDescription('Name of the ticket category.') + .setAutocomplete(true) + .setRequired(true) + ) + .addStringOption(option => + option.setName('question') + .setDescription('Question to remove.') + .setAutocomplete(true) + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('list') + .setDescription('List all ticket categories.') + ) + .addSubcommand(subcommand => + subcommand + .setName('timeout') + .setDescription('Set the default ticket timeout after a close request.') + .addStringOption(option => + option.setName('category') + .setDescription('Name of the ticket category.') + .setAutocomplete(true) + .setRequired(true) + ) + .addIntegerOption(option => + option.setName('timeout') + .setDescription('Timeout in days.') + .setRequired(true) + ) + ), + async execute(interaction) { + if (process.env.MANAGER_ROLE_ID && !interaction.member.roles.cache.has(process.env.MANAGER_ROLE_ID)) { + return await interaction.reply({ content: 'You do not have permission to use this command.', ephemeral: true }); + } + + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'create') { + const name = interaction.options.getString('name'); + const color = interaction.options.getString('color'); + const logChannel = interaction.options.getChannel('log_channel'); + const emoji = interaction.options.getString('emoji'); + const category = interaction.options.getChannel('category'); + + await db('buttons').insert({ + label: name, + style: color, + log_channel_id: logChannel.id, + category_id: category.id, + emoji: emoji + }); + + await interaction.reply({ content: 'Ticket category created!', ephemeral: true }); + } + + else if (subcommand === 'edit') { + const name = interaction.options.getString('name'); + const color = interaction.options.getString('color'); + const logChannel = interaction.options.getChannel('log_channel'); + const emoji = interaction.options.getString('emoji'); + const category = interaction.options.getChannel('category'); + + const options = {}; + if (color) options.style = color; + if (logChannel) options.log_channel_id = logChannel.id; + if (emoji) options.emoji = emoji; + if (category) options.category_id = category.id; + + await db('buttons').where({ label: name }).update(options); + + await interaction.reply({ content: 'Ticket category edited!', ephemeral: true }); + } + + else if (subcommand === 'delete') { + const name = interaction.options.getString('name'); + + await db('buttons').where({ label: name }).del(); + + await interaction.reply({ content: 'Ticket category deleted!', ephemeral: true }); + } + + else if (subcommand === 'add_question') { + const name = interaction.options.getString('category'); + const question = interaction.options.getString('question'); + const short = interaction.options.getBoolean('short'); + + const category = await db('buttons').where({ label: name }).first(); + + let qField; + + if (!category.q1) qField = 'q1'; + else if (!category.q2) qField = 'q2'; + else if (!category.q3) qField = 'q3'; + else if (!category.q4) qField = 'q4'; + else if (!category.q5) qField = 'q5'; + else return await interaction.reply({ content: 'This category already has 5 questions!', ephemeral: true }); + + const options = {}; + options[qField] = question; + options[`t${qField[1]}`] = short; + + await db('buttons').where({ label: name }).update(options); + + await interaction.reply({ content: 'Question added!', ephemeral: true }); + } + + else if (subcommand === 'remove_question') { + const name = interaction.options.getString('category'); + const question = interaction.options.getString('question'); + + const category = await db('buttons').where({ label: name }).first(); + + let qField; + + if (category.q1 === question) qField = 'q1'; + else if (category.q2 === question) qField = 'q2'; + else if (category.q3 === question) qField = 'q3'; + else if (category.q4 === question) qField = 'q4'; + else qField = 'q5'; + + const options = {}; + options[qField] = null; + options[`t${qField[1]}`] = null; + + await db('buttons').where({ label: name }).update(options); + + await interaction.reply({ content: 'Question removed!', ephemeral: true }); + } + + else if (subcommand === 'list') { + const categories = await db('buttons').select('label'); + + await interaction.reply({ content: categories.map(category => category.label).join(', '), ephemeral: true }); + } + + else if (subcommand === 'timeout') { + const timeout = interaction.options.getInteger('timeout'); + const category = interaction.options.getString('category'); + + await db('buttons') + .update({ timeout: timeout }) + .where({ label: category }); + + await interaction.reply({ content: `Timeout set to ${timeout} days!`, ephemeral: true }); + } + } +} diff --git a/knexfile.js b/knexfile.js new file mode 100644 index 0000000..b48b22c --- /dev/null +++ b/knexfile.js @@ -0,0 +1,21 @@ +require("dotenv").config(); + +module.exports = { + client: "pg", + connection: { + user: process.env.DB_USER, + password: process.env.DB_PASS, + host: process.env.DB_HOST, + port: process.env.DB_PORT, + database: process.env.DB_NAME, + }, + version: 13, + pool: { + min: 2, + max: 10, + }, + migrations: { + tableName: "knex_migrations", + directory: "./migrations", + }, +} \ No newline at end of file diff --git a/messages/defaultButtonError.js b/messages/defaultButtonError.js new file mode 100644 index 0000000..0ac4ce2 --- /dev/null +++ b/messages/defaultButtonError.js @@ -0,0 +1,9 @@ +module.exports = { + async execute(interaction) { + await interaction.reply({ + content: "There was an issue while fetching this button!", + ephemeral: true, + }); + return; + }, +}; diff --git a/messages/defaultModalError.js b/messages/defaultModalError.js new file mode 100644 index 0000000..0caec62 --- /dev/null +++ b/messages/defaultModalError.js @@ -0,0 +1,9 @@ +module.exports = { + async execute(interaction) { + await interaction.reply({ + content: "There was an issue while fetching this modal!", + ephemeral: true, + }); + return; + }, +}; diff --git a/messages/defaultSelectError.js b/messages/defaultSelectError.js new file mode 100644 index 0000000..9924fb3 --- /dev/null +++ b/messages/defaultSelectError.js @@ -0,0 +1,9 @@ +module.exports = { + async execute(interaction) { + await interaction.reply({ + content: "There was an issue while fetching this select menu option!", + ephemeral: true, + }); + return; + }, +}; diff --git a/migrations/20240313161209_buttons.js b/migrations/20240313161209_buttons.js new file mode 100644 index 0000000..b4935b7 --- /dev/null +++ b/migrations/20240313161209_buttons.js @@ -0,0 +1,23 @@ +exports.up = async function (knex) { + await knex.schema.createTable("buttons", (table) => { + table.increments("id").primary(); + table.string("label").notNullable(); + table.string("style").notNullable(); + table.string("log_channel_id").notNullable(); + table.string("category_id").notNullable(); + table.string("timeout"); + table.string("emoji"); + table.string("q1"); // Question + table.boolean("t1"); // Type of question (long or short) + table.string("q2"); + table.boolean("t2"); + table.string("q3"); + table.boolean("t3"); + table.string("q4"); + table.boolean("t4"); + table.string("q5"); + table.boolean("t5"); + }); +}; + +exports.down = function (knex) { }; \ No newline at end of file diff --git a/migrations/20240313161530_tickets.js b/migrations/20240313161530_tickets.js new file mode 100644 index 0000000..cff3d49 --- /dev/null +++ b/migrations/20240313161530_tickets.js @@ -0,0 +1,21 @@ +exports.up = async function (knex) { + await knex.schema.createTable("tickets", (table) => { + table.increments("id").primary(); + table.integer("ticket_id") + table + .foreign("ticket_id") + .references("id") + .inTable("buttons") + table.string("user_id").notNullable(); + table.string("category_id").notNullable(); + table.string("status").notNullable(); + table.string("channel_id").notNullable(); + table.string("log_channel_id").notNullable() + table.string("close_requested_at") + table.string("closed_by") + table.timestamp("created_at").defaultTo(knex.fn.now()); + }); +}; + + +exports.down = function (knex) { }; \ No newline at end of file diff --git a/migrations/20240317182235_settings.js b/migrations/20240317182235_settings.js new file mode 100644 index 0000000..eac060c --- /dev/null +++ b/migrations/20240317182235_settings.js @@ -0,0 +1,7 @@ +exports.up = async function (knex) { + await knex.schema.createTable("settings", (table) => { + table.specificType("roles", "text ARRAY"); + }); +}; + +exports.down = function (knex) { }; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..e90d702 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "bsmg-tickets", + "version": "0.1.0", + "description": "A ticketing bot for BSMG", + "main": "bot.js", + "scripts": { + "start": "node bot.js", + "make:migration": "knex migrate:make", + "migrate": "knex migrate:latest", + "initiate": "yarn && yarn migrate && yarn start" + }, + "license": "Apache-2.0", + "dependencies": { + "@discordjs/rest": "^1.0.0", + "discord-api-types": "^0.36.2", + "discord-html-transcripts": "^3.2.0", + "discord.js": "^14.0.2", + "dotenv": "^16.4.5", + "knex": "^3.1.0", + "pg": "^8.11.3" + } +}