diff --git a/src/autocomplete/references.js b/src/autocomplete/references.js index 5d3c128..bf80611 100644 --- a/src/autocomplete/references.js +++ b/src/autocomplete/references.js @@ -8,5 +8,37 @@ module.exports = class ReferencesCompleter extends Autocompleter { }); } - async run(value, comamnd, interaction) { } + /** + * @param {string} value + * @param {*} comamnd + * @param {import("discord.js").AutocompleteInteraction} interaction + */ + async run(value, comamnd, interaction) { + /** @type {import("client")} */ + const client = this.client; + const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } }); + const tickets = await client.prisma.ticket.findMany({ + where: { + createdById: interaction.user.id, + guildId: interaction.guild.id, + open: false, + }, + }); + const options = value ? tickets.filter(t => + String(t.number).match(new RegExp(value, 'i')) || + t.topic?.match(new RegExp(value, 'i')) || + new Date(t.createdAt).toLocaleString(settings.locale, { dateStyle: 'short' })?.match(new RegExp(value, 'i')), + ) : tickets; + await interaction.respond( + options + .slice(0, 25) + .map(t => { + const date = new Date(t.createdAt).toLocaleString(settings.locale, { dateStyle: 'short' }); + return { + name: `#${t.number} - ${date} ${t.topic ? '| ' + t.topic.substring(0, 50) : ''}`, + value: t.id, + }; + }), + ); + } }; \ No newline at end of file diff --git a/src/commands/message/create.js b/src/commands/message/create.js index ba8f240..cda68bd 100644 --- a/src/commands/message/create.js +++ b/src/commands/message/create.js @@ -1,4 +1,5 @@ const { MessageCommand } = require('@eartharoid/dbf'); +const { useGuild } = require('../../lib/tickets/utils'); module.exports = class CreateMessageCommand extends MessageCommand { constructor(client, options) { @@ -13,7 +14,11 @@ module.exports = class CreateMessageCommand extends MessageCommand { }); } + /** + * @param {import("discord.js").MessageContextMenuCommandInteraction} interaction + */ async run(interaction) { // TODO: archive message + await useGuild(this.client, interaction, { referencesMessage: interaction.targetMessage.channelId + '/' + interaction.targetId }); } }; \ No newline at end of file diff --git a/src/commands/slash/new.js b/src/commands/slash/new.js index 2fd5da2..4b0aedc 100644 --- a/src/commands/slash/new.js +++ b/src/commands/slash/new.js @@ -1,5 +1,6 @@ const { SlashCommand } = require('@eartharoid/dbf'); const { ApplicationCommandOptionType } = require('discord.js'); +const { useGuild } = require('../../lib/tickets/utils'); module.exports = class NewSlashCommand extends SlashCommand { constructor(client, options) { @@ -14,7 +15,7 @@ module.exports = class NewSlashCommand extends SlashCommand { autocomplete: true, name: 'references', required: false, - type: ApplicationCommandOptionType.Integer, + type: ApplicationCommandOptionType.String, }, ]; opts = opts.map(o => { @@ -43,5 +44,12 @@ module.exports = class NewSlashCommand extends SlashCommand { }); } - async run(interaction) { } + /** + * + * @param {import("discord.js").ChatInputCommandInteraction} interaction + */ + async run(interaction) { + await useGuild(this.client, interaction, { referencesTicketId: interaction.options.getString('references', false) }); + + } }; \ No newline at end of file diff --git a/src/i18n/en-GB.yml b/src/i18n/en-GB.yml index 4009a7d..560edf7 100644 --- a/src/i18n/en-GB.yml +++ b/src/i18n/en-GB.yml @@ -194,7 +194,9 @@ misc: member_limit: description: - Please use your existing ticket or close it before creating another. - - Please close a ticket before creating another. + - | + Please close a ticket before creating another. + Use `/tickets` to view your existing tickets. title: - ❌ You already have a ticket - ❌ You already have %d open tickets @@ -227,4 +229,14 @@ ticket: {staff} {creator} has created a new ticket fields: - topic: Topic \ No newline at end of file + topic: Topic + references_message: + description: 'References [a message]({url}) sent {timestamp} by {author}.' + title: ℹ️ Reference + references_ticket: + description: 'This ticket is related to a previous ticket:' + fields: + date: Created at + number: Number + topic: Topic + title: ℹ️ Reference diff --git a/src/lib/tickets/archiver.js b/src/lib/tickets/archiver.js new file mode 100644 index 0000000..de41890 --- /dev/null +++ b/src/lib/tickets/archiver.js @@ -0,0 +1,11 @@ +const Cryptr = require('cryptr'); +const cryptr = new Cryptr(process.env.ENCRYPTION_KEY); + +module.exports = class TicketArchiver { + constructor(client) { + /** @type {import("client")} */ + this.client = client; + } + + async addMessage() {} +}; \ No newline at end of file diff --git a/src/lib/tickets/manager.js b/src/lib/tickets/manager.js index b24f1be..74f125c 100644 --- a/src/lib/tickets/manager.js +++ b/src/lib/tickets/manager.js @@ -1,4 +1,5 @@ /* eslint-disable max-lines */ +const TicketArchiver = require('./archiver'); const { ActionRowBuilder, ButtonBuilder, @@ -24,7 +25,7 @@ module.exports = class TicketManager { constructor(client) { /** @type {import("client")} */ this.client = client; - + this.archiver = new TicketArchiver(client); this.$ = { categories: {} }; } @@ -92,7 +93,7 @@ module.exports = class TicketManager { * @param {string?} [data.topic] */ async create({ - categoryId, interaction, topic, referencesMessage, referencesTicket, + categoryId, interaction, topic, referencesMessage, referencesTicketId, }) { categoryId = Number(categoryId); const category = await this.getCategory(categoryId); @@ -122,6 +123,9 @@ module.exports = class TicketManager { }); } + /** @type {import("discord.js").Guild} */ + const guild = this.client.guilds.cache.get(category.guild.id); + const member = interaction.member ?? await guild.members.fetch(interaction.user.id); const getMessage = this.client.i18n.getLocale(category.guild.locale); const rlKey = `ratelimits/guild-user:${category.guildId}-${interaction.user.id}`; @@ -130,7 +134,7 @@ module.exports = class TicketManager { return await interaction.reply({ embeds: [ new ExtendedEmbedBuilder({ - iconURL: interaction.guild.iconURL(), + iconURL: guild.iconURL(), text: category.guild.footer, }) .setColor(category.guild.errorColour) @@ -146,7 +150,7 @@ module.exports = class TicketManager { const sendError = name => interaction.reply({ embeds: [ new ExtendedEmbedBuilder({ - iconURL: interaction.guild.iconURL(), + iconURL: guild.iconURL(), text: category.guild.footer, }) .setColor(category.guild.errorColour) @@ -156,10 +160,6 @@ module.exports = class TicketManager { ephemeral: true, }); - /** @type {import("discord.js").Guild} */ - const guild = this.client.guilds.cache.get(category.guild.id); - const member = interaction.member ?? await guild.members.fetch(interaction.user.id); - if (category.guild.blocklist.length !== 0) { const blocked = category.guild.blocklist.some(r => member.roles.cache.has(r)); if (blocked) return await sendError('blocked'); @@ -181,7 +181,7 @@ module.exports = class TicketManager { return await interaction.reply({ embeds: [ new ExtendedEmbedBuilder({ - iconURL: interaction.guild.iconURL(), + iconURL: guild.iconURL(), text: category.guild.footer, }) .setColor(category.guild.errorColour) @@ -206,7 +206,7 @@ module.exports = class TicketManager { // return await interaction.reply({ // embeds: [ // new ExtendedEmbedBuilder({ - // iconURL: interaction.guild.iconURL(), + // iconURL: guild.iconURL(), // text: category.guild.footer, // }) // .setColor(category.guild.errorColour) @@ -222,7 +222,7 @@ module.exports = class TicketManager { return await interaction.reply({ embeds: [ new ExtendedEmbedBuilder({ - iconURL: interaction.guild.iconURL(), + iconURL: guild.iconURL(), text: category.guild.footer, }) .setColor(category.guild.errorColour) @@ -240,7 +240,7 @@ module.exports = class TicketManager { action: 'questions', categoryId, referencesMessage, - referencesTicket, + referencesTicketId, })) .setTitle(category.name) .setComponents( @@ -290,7 +290,7 @@ module.exports = class TicketManager { action: 'topic', categoryId, referencesMessage, - referencesTicket, + referencesTicketId, })) .setTitle(category.name) .setComponents( @@ -311,6 +311,8 @@ module.exports = class TicketManager { await this.postQuestions({ categoryId, interaction, + referencesMessage, + referencesTicketId, topic, }); } @@ -323,7 +325,7 @@ module.exports = class TicketManager { * @param {string?} [data.topic] */ async postQuestions({ - action, categoryId, interaction, topic, referencesMessage, referencesTicket, + action, categoryId, interaction, topic, referencesMessage, referencesTicketId, }) { await interaction.deferReply({ ephemeral: true }); @@ -480,6 +482,69 @@ module.exports = class TicketManager { // TODO: referenced msg or ticket + if (referencesMessage) { + referencesMessage = referencesMessage.split('/'); + /** @type {import("discord.js").Message} */ + const message = await (await this.client.channels.fetch(referencesMessage[0]))?.messages.fetch(referencesMessage[1]); + if (message) { + await channel.send({ + embeds: [ + new ExtendedEmbedBuilder() + .setColor(category.guild.primaryColour) + .setTitle(getMessage('ticket.references_message.title')) + .setDescription( + getMessage('ticket.references_message.description', { + author: message.author.toString(), + timestamp: ``, + url: message.url, + })), + new ExtendedEmbedBuilder({ + iconURL: guild.iconURL(), + text: category.guild.footer, + }) + .setColor(category.guild.primaryColour) + .setAuthor({ + iconURL: message.member?.displayAvatarURL(), + name: message.member?.displayName || 'Unknown', + }) + .setDescription(message.content.substring(0, 1000) + message.content.length > 1000 ? '...' : ''), + ], + }); + } + } else if (referencesTicketId) { + // TODO: add portal url + const ticket = await this.client.prisma.ticket.findUnique({ where: { id: referencesTicketId } }); + if (ticket) { + const embed = new ExtendedEmbedBuilder({ + iconURL: guild.iconURL(), + text: category.guild.footer, + }) + .setColor(category.guild.primaryColour) + .setTitle(getMessage('ticket.references_ticket.title')) + .setDescription(getMessage('ticket.references_ticket.description')) + .setFields([ + { + inline: true, + name: getMessage('ticket.references_ticket.fields.number'), + value: inlineCode(ticket.number), + }, + { + inline: true, + name: getMessage('ticket.references_ticket.fields.date'), + value: ``, + }, + ]); + if (ticket.topic) { + embed.addFields({ + inline: false, + name: getMessage('ticket.references_ticket.fields.topic'), + value: ticket.topic, + }); + } + await channel.send({ embeds: [embed] }); + } + } + const data = { category: { connect: { id: categoryId } }, createdBy: { @@ -494,10 +559,10 @@ module.exports = class TicketManager { openingMessageId: sent.id, topic, }; - if (referencesTicket) data.referencesTicket = { connect: { id: referencesTicket } }; + if (referencesTicketId) data.referencesTicket = { connect: { id: referencesTicketId } }; let message; - if (referencesMessage) message = this.client.prisma.archivedMessage.findUnique({ where: { id: referencesMessage } }); - if (message) data.referencesMessage = { connect: { id: referencesMessage } }; // only add if the message has been archived ^^ + if (referencesMessage) message = await this.client.prisma.archivedMessage.findUnique({ where: { id: referencesMessage[1] } }); + if (message) data.referencesMessage = { connect: { id: referencesMessage[0] } }; // only add if the message has been archived ^^ if (answers) data.questionAnswers = { createMany: { data: answers } }; await interaction.editReply({ components: [], diff --git a/src/lib/tickets/utils.js b/src/lib/tickets/utils.js new file mode 100644 index 0000000..1fac854 --- /dev/null +++ b/src/lib/tickets/utils.js @@ -0,0 +1,77 @@ +const { + ActionRowBuilder, + EmbedBuilder, + SelectMenuBuilder, + SelectMenuOptionBuilder, +} = require('discord.js'); +const emoji = require('node-emoji'); + +module.exports = { + /** + * @param {import("client")} client + * @param {import("discord.js").ButtonInteraction|import("discord.js").SelectMenuInteraction} interaction + */ + async useGuild(client, interaction, { + referencesMessage, + referencesTicketId, + topic, + }) { + const settings = await client.prisma.guild.findUnique({ + select: { + categories: true, + errorColour: true, + locale: true, + primaryColour: true, + }, + where: { id: interaction.guild.id }, + }); + const getMessage = client.i18n.getLocale(settings.locale); + if (settings.categories.length === 0) { + interaction.reply({ + components: [], + embeds: [ + new EmbedBuilder() + .setColor(settings.errorColour) + .setTitle(getMessage('misc.no_categories.title')) + .setDescription(getMessage('misc.no_categories.description')), + ], + ephemeral: true, + }); + } else if (settings.categories.length === 1) { + await client.tickets.create({ + categoryId: settings.categories[0].id, + interaction, + referencesMessage, + referencesTicketId, + topic, + }); + } else { + await interaction.reply({ + components: [ + new ActionRowBuilder() + .setComponents( + new SelectMenuBuilder() + .setCustomId(JSON.stringify({ + action: 'create', + referencesMessage, + referencesTicketId, + topic, + })) + .setPlaceholder(getMessage('menus.category.placeholder')) + .setOptions( + settings.categories.map(category => + new SelectMenuOptionBuilder() + .setValue(String(category.id)) + .setLabel(category.name) + .setDescription(category.description) + .setEmoji(emoji.hasEmoji(category.emoji) ? emoji.get(category.emoji) : { id: category.emoji }), + ), + ), + ), + ], + ephemeral: true, + }); + } + + }, +}; \ No newline at end of file diff --git a/src/listeners/client/messageCreate.js b/src/listeners/client/messageCreate.js index 7942cfa..efa50f1 100644 --- a/src/listeners/client/messageCreate.js +++ b/src/listeners/client/messageCreate.js @@ -23,13 +23,13 @@ module.exports = class extends Listener { } /** - * @param {string} guildId + * @param {import('@prisma/client').Guild} settings * @param {import("discord.js").ButtonInteraction|import("discord.js").SelectMenuInteraction} interaction */ async useGuild(settings, interaction, topic) { const getMessage = this.client.i18n.getLocale(settings.locale); if (settings.categories.length === 0) { - interaction.editReply({ + interaction.update({ components: [], embeds: [ new EmbedBuilder() @@ -45,7 +45,7 @@ module.exports = class extends Listener { topic, }); } else { - const sent = await interaction.editReply({ + await interaction.update({ components: [ new ActionRowBuilder() .setComponents( @@ -67,17 +67,17 @@ module.exports = class extends Listener { ), ], }); - sent.awaitMessageComponent({ + interaction.message.awaitMessageComponent({ componentType: ComponentType.SelectMenu, filter: () => true, time: ms('30s'), }) .then(async () => { - await sent.delete(); + interaction.message.delete(); }) .catch(error => { if (error) this.client.log.error(error); - sent.delete(); + interaction.message.delete(); }); } @@ -92,7 +92,7 @@ module.exports = class extends Listener { if (message.channel.type === ChannelType.DM) { if (message.author.bot) return false; - const commonGuilds = await getCommonGuilds(this.client, message.author.id); + const commonGuilds = await getCommonGuilds(client, message.author.id); if (commonGuilds.size === 0) { return false; } else if (commonGuilds.size === 1) { @@ -105,7 +105,7 @@ module.exports = class extends Listener { }, where: { id: commonGuilds.at(0).id }, }); - const getMessage = this.client.i18n.getLocale(settings.locale); + const getMessage = client.i18n.getLocale(settings.locale); const sent = await message.reply({ components: [ new ActionRowBuilder() @@ -126,16 +126,16 @@ module.exports = class extends Listener { }); sent.awaitMessageComponent({ componentType: ComponentType.Button, - filter: interaction => interaction.deferUpdate(), + filter: () => true, time: ms('30s'), }) .then(async interaction => await this.useGuild(settings, interaction, message.content)) .catch(error => { - if (error) this.client.log.error(error); + if (error) client.log.error(error); sent.delete(); }); } else { - const getMessage = this.client.i18n.getLocale(); + const getMessage = client.i18n.getLocale(); const sent = await message.reply({ components: [ new ActionRowBuilder() @@ -156,7 +156,7 @@ module.exports = class extends Listener { }); sent.awaitMessageComponent({ componentType: ComponentType.SelectMenu, - filter: interaction => interaction.deferUpdate(), + filter: () => true, time: ms('30s'), }) .then(async interaction => { @@ -172,7 +172,7 @@ module.exports = class extends Listener { await this.useGuild(settings, interaction, message.content); }) .catch(error => { - if (error) this.client.log.error(error); + if (error) client.log.error(error); sent.delete(); }); } diff --git a/src/listeners/client/ready.js b/src/listeners/client/ready.js index cd80e1d..9287648 100644 --- a/src/listeners/client/ready.js +++ b/src/listeners/client/ready.js @@ -25,11 +25,16 @@ module.exports = class extends Listener { cooldown: true, id: true, tickets: { - select: { createdById: true }, + select: { + createdById: true, + guildId: true, + id: true, + }, where: { open: true }, }, }, }); + let deleted = 0; let ticketCount = 0; let cooldowns = 0; for (const category of categories) { @@ -38,6 +43,13 @@ module.exports = class extends Listener { for (const ticket of category.tickets) { if (client.tickets.$.categories[category.id][ticket.createdById]) client.tickets.$.categories[category.id][ticket.createdById]++; else client.tickets.$.categories[category.id][ticket.createdById] = 1; + /** @type {import("discord.js").Guild} */ + const guild = client.guilds.cache.get(ticket.guildId); + if (guild && guild.available && !client.channels.cache.has(ticket.id)) { + deleted += 0; + await client.tickets.close(ticket.id); + } + } if (category.cooldown) { const recent = await client.prisma.ticket.findMany({ @@ -63,6 +75,7 @@ module.exports = class extends Listener { // const ticketCount = categories.reduce((total, category) => total + category.tickets.length, 0); client.log.info(`Cached ticket count of ${categories.length} categories (${ticketCount} open tickets)`); client.log.info(`Loaded ${cooldowns} active cooldowns`); + client.log.info(`Closed ${deleted} deleted tickets`); // presence/activity let next = 0; diff --git a/src/menus/create.js b/src/menus/create.js index e0b282d..47943f4 100644 --- a/src/menus/create.js +++ b/src/menus/create.js @@ -1,4 +1,5 @@ const { Menu } = require('@eartharoid/dbf'); +const { MessageFlags } = require('discord.js'); module.exports = class CreateMenu extends Menu { constructor(client, options) { @@ -13,11 +14,11 @@ module.exports = class CreateMenu extends Menu { * @param {import("discord.js").SelectMenuInteraction} interaction */ async run(id, interaction) { - interaction.message.edit({ components: interaction.message.components }); // reset the select menu (minor client-side UI issue) + if (!interaction.message.flags.has(MessageFlags.Ephemeral)) interaction.message.edit({ components: interaction.message.components }); // reset the select menu (minor client-side UI issue) await this.client.tickets.create({ + ...id, categoryId: interaction.values[0], interaction, - topic: id.topic, }); } }; \ No newline at end of file