diff --git a/db/mysql/schema.prisma b/db/mysql/schema.prisma index 6f25802c9..e91ebaca0 100644 --- a/db/mysql/schema.prisma +++ b/db/mysql/schema.prisma @@ -101,10 +101,9 @@ model Feedback { createdAt DateTime @default(now()) guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) guildId String @db.VarChar(19) - id Int @id @default(autoincrement()) rating Int ticket Ticket @relation(fields: [ticketId], references: [id]) - ticketId String @unique @db.VarChar(19) + ticketId String @id @db.VarChar(19) user User? @relation(fields: [userId], references: [id]) userId String? @db.VarChar(19) @@ -199,10 +198,9 @@ model Ticket { createdAt DateTime @default(now()) createdBy User @relation(name: "TicketsCreatedByUser", fields: [createdById], references: [id]) createdById String @db.VarChar(19) + deleted Boolean @default(false) feedback Feedback? - feedbackId Int? firstResponseAt DateTime? - deleted Boolean @default(false) guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) guildId String @db.VarChar(19) id String @id @db.VarChar(19) diff --git a/db/postgresql/schema.prisma b/db/postgresql/schema.prisma index 02f97c8cd..d06520c8c 100644 --- a/db/postgresql/schema.prisma +++ b/db/postgresql/schema.prisma @@ -101,10 +101,9 @@ model Feedback { createdAt DateTime @default(now()) guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) guildId String @db.VarChar(19) - id Int @id @default(autoincrement()) rating Int ticket Ticket @relation(fields: [ticketId], references: [id]) - ticketId String @unique @db.VarChar(19) + ticketId String @id @db.VarChar(19) user User? @relation(fields: [userId], references: [id]) userId String? @db.VarChar(19) @@ -199,10 +198,9 @@ model Ticket { createdAt DateTime @default(now()) createdBy User @relation(name: "TicketsCreatedByUser", fields: [createdById], references: [id]) createdById String @db.VarChar(19) + deleted Boolean @default(false) feedback Feedback? - feedbackId Int? firstResponseAt DateTime? - deleted Boolean @default(false) guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) guildId String @db.VarChar(19) id String @id @db.VarChar(19) diff --git a/db/sqlite/schema.prisma b/db/sqlite/schema.prisma index 6e455413a..8581650c7 100644 --- a/db/sqlite/schema.prisma +++ b/db/sqlite/schema.prisma @@ -101,10 +101,9 @@ model Feedback { createdAt DateTime @default(now()) guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) guildId String - id Int @id @default(autoincrement()) rating Int ticket Ticket @relation(fields: [ticketId], references: [id]) - ticketId String @unique + ticketId String @id user User? @relation(fields: [userId], references: [id]) userId String? @@ -199,10 +198,9 @@ model Ticket { createdAt DateTime @default(now()) createdBy User @relation(name: "TicketsCreatedByUser", fields: [createdById], references: [id]) createdById String + deleted Boolean @default(false) feedback Feedback? - feedbackId Int? firstResponseAt DateTime? - deleted Boolean @default(false) guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) guildId String id String @id diff --git a/package.json b/package.json index 7d82f2e85..7c57e2e96 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,8 @@ "@fastify/http-proxy": "^8.4.0", "@fastify/jwt": "^5.0.1", "@fastify/oauth2": "^5.1.0", - "@prisma/client": "^4.8.0", - "cryptr": "^6.0.3", + "@prisma/client": "^4.8.1", + "cryptr": "^6.1.0", "discord.js": "^14.7.1", "dotenv": "^16.0.3", "express": "^4.18.2", @@ -60,7 +60,7 @@ "node-emoji": "^1.11.0", "object-diffy": "^1.0.4", "pad": "^3.2.0", - "prisma": "^4.8.0", + "prisma": "^4.8.1", "semver": "^7.3.8", "terminal-link": "^2.1.1", "yaml": "^1.10.2" diff --git a/src/buttons/close.js b/src/buttons/close.js index 50a25a39b..1a5cd541d 100644 --- a/src/buttons/close.js +++ b/src/buttons/close.js @@ -1,4 +1,5 @@ const { Button } = require('@eartharoid/dbf'); +const { isStaff } = require('../lib/users'); module.exports = class CloseButton extends Button { constructor(client, options) { @@ -16,6 +17,29 @@ module.exports = class CloseButton extends Button { /** @type {import("client")} */ const client = this.client; - await interaction.deferReply(); + if (id.accepted === undefined) { + await client.tickets.beforeRequestClose(interaction); + } else { + // { + // action: 'close', + // expect: staff ? 'user' : 'staff', + // reason: interaction.options?.getString('reason', false) || null, // ?. because it could be a button interaction + // requestedBy: interaction.user.id, + // } + + await interaction.deferReply(); + const ticket = await client.prisma.ticket.findUnique({ + include: { guild: true }, + where: { id: interaction.channel.id }, + }); + + if (id.expect === 'staff' && !await isStaff(interaction.guild, interaction.user.id)) { + return; + } else if (interaction.user.id !== ticket.createdById) { + return; + // if user and expect user (or is creator), feedback modal (if enabled) + // otherwise add "Give feedback" button in DM message (if enabled) + } + } } }; \ No newline at end of file diff --git a/src/buttons/edit.js b/src/buttons/edit.js index 208e02734..96e176483 100644 --- a/src/buttons/edit.js +++ b/src/buttons/edit.js @@ -2,8 +2,8 @@ const { Button } = require('@eartharoid/dbf'); const { ActionRowBuilder, ModalBuilder, - SelectMenuBuilder, - SelectMenuOptionBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, TextInputBuilder, TextInputStyle, } = require('discord.js'); @@ -86,14 +86,14 @@ module.exports = class EditButton extends Button { } else if (a.question.type === 'MENU') { return new ActionRowBuilder() .setComponents( - new SelectMenuBuilder() + new StringSelectMenuBuilder() .setCustomId(a.question.id) .setPlaceholder(a.question.placeholder || a.question.label) .setMaxValues(a.question.maxLength) .setMinValues(a.question.minLength) .setOptions( a.question.options.map((o, i) => { - const builder = new SelectMenuOptionBuilder() + const builder = new StringSelectMenuOptionBuilder() .setValue(String(i)) .setLabel(o.label); if (o.description) builder.setDescription(o.description); diff --git a/src/client.js b/src/client.js index 0e91785a9..b34f4aee6 100644 --- a/src/client.js +++ b/src/client.js @@ -1,6 +1,7 @@ const { FrameworkClient } = require('@eartharoid/dbf'); const { - GatewayIntentBits, Partials, + GatewayIntentBits, + Partials, } = require('discord.js'); const { PrismaClient } = require('@prisma/client'); const Keyv = require('keyv'); diff --git a/src/commands/slash/close.js b/src/commands/slash/close.js index 0086c0ad6..af0010bcc 100644 --- a/src/commands/slash/close.js +++ b/src/commands/slash/close.js @@ -30,6 +30,8 @@ module.exports = class CloseSlashCommand extends SlashCommand { * @param {import("discord.js").ChatInputCommandInteraction} interaction */ async run(interaction) { - + /** @type {import("client")} */ + const client = this.client; + await client.tickets.beforeRequestClose(interaction); } }; \ No newline at end of file diff --git a/src/commands/slash/force-close.js b/src/commands/slash/force-close.js index 2e16b756e..dea68f1ba 100644 --- a/src/commands/slash/force-close.js +++ b/src/commands/slash/force-close.js @@ -228,7 +228,5 @@ module.exports = class ForceCloseSlashCommand extends SlashCommand { }); } } - - // TODO: close (reason) } }; \ No newline at end of file diff --git a/src/commands/slash/move.js b/src/commands/slash/move.js index 2dc3b04c8..335699ce0 100644 --- a/src/commands/slash/move.js +++ b/src/commands/slash/move.js @@ -85,8 +85,8 @@ module.exports = class MoveSlashCommand extends SlashCommand { where: { id: ticket.id }, }); - const $oldCategory = client.tickets.$.categories[ticket.categoryId]; - const $newCategory = client.tickets.$.categories[newCategory.id]; + const $oldCategory = client.tickets.$count.categories[ticket.categoryId]; + const $newCategory = client.tickets.$count.categories[newCategory.id]; $oldCategory.total--; $oldCategory[ticket.createdById]--; diff --git a/src/commands/slash/transfer.js b/src/commands/slash/transfer.js index 785ad0b1b..aa03f9823 100644 --- a/src/commands/slash/transfer.js +++ b/src/commands/slash/transfer.js @@ -44,7 +44,7 @@ module.exports = class TransferSlashCommand extends SlashCommand { let ticket = await client.prisma.ticket.findUnique({ where: { id: interaction.channel.id } }); const from = ticket.createdById; - console.log(1) + ticket = await client.prisma.ticket.update({ data: { createdBy: { diff --git a/src/i18n/en-GB.yml b/src/i18n/en-GB.yml index 9c9fd9c71..0be9cef06 100644 --- a/src/i18n/en-GB.yml +++ b/src/i18n/en-GB.yml @@ -1,6 +1,9 @@ buttons: + accept_close_request: + emoji: ✅ + text: Accept cancel: - emoji: 🚫 + emoji: ✖️ text: Cancel claim: emoji: 🙌 @@ -17,6 +20,9 @@ buttons: edit: emoji: ✏️ text: Edit + reject_close_request: + emoji: ✖️ + text: Reject unclaim: emoji: ♻️ text: Release @@ -328,7 +334,13 @@ misc: title: ❌ That ticket category doesn't exist modals: feedback: - title: Feedback + comment: + label: Comment + placeholder: Do you have any additional feedback? + rating: + label: Rating + placeholder: 1-5 + title: How did we do? topic: label: Topic placeholder: What is this ticket about? @@ -336,6 +348,20 @@ ticket: answers: no_value: "*No response*" claimed: 🙌 {user} has claimed this ticket. + close: + forbidden: + description: You don't have permission to close this ticket. + title: ❌ Error + staff_request: + archived: | + + The messages in this channel will be archived for future reference. + description: | + {requestedBy} wants to close this ticket. + Click "Accept" to close it now, or "Reject" if you still need help. + title: ❓ Can this ticket be closed? + user_request: + title: ❓ {requestedBy} wants to close this ticket created: description: "Your ticket channel has been created: {channel}." title: ✅ Ticket created diff --git a/src/lib/sync.js b/src/lib/sync.js index 0d186a539..e7022b3e8 100644 --- a/src/lib/sync.js +++ b/src/lib/sync.js @@ -22,10 +22,10 @@ module.exports = async client => { let cooldowns = 0; for (const category of categories) { ticketCount += category.tickets.length; - client.tickets.$.categories[category.id] = { total: category.tickets.length }; + client.tickets.$count.categories[category.id] = { total: category.tickets.length }; 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; + if (client.tickets.$count.categories[category.id][ticket.createdById]) client.tickets.$count.categories[category.id][ticket.createdById]++; + else client.tickets.$count.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)) { diff --git a/src/lib/tickets/manager.js b/src/lib/tickets/manager.js index bb926acec..9143a8e34 100644 --- a/src/lib/tickets/manager.js +++ b/src/lib/tickets/manager.js @@ -16,6 +16,7 @@ const emoji = require('node-emoji'); const ms = require('ms'); const ExtendedEmbedBuilder = require('../embed'); const { logTicketEvent } = require('../logging'); +const { isStaff } = require('../users'); const { Collection } = require('discord.js'); const Cryptr = require('cryptr'); const { encrypt } = new Cryptr(process.env.ENCRYPTION_KEY); @@ -30,14 +31,21 @@ module.exports = class TicketManager { /** @type {import("client")} */ this.client = client; this.archiver = new TicketArchiver(client); - this.$ = { categories: {} }; + this.$count = { categories: {} }; + this.$stale = new Collection(); } - async getCategory(categoryId) { + /** + * Retrieve cached category data + * @param {string} categoryId the category ID + * @param {boolean} force bypass & update the cache? + * @returns {Promise} + */ + async getCategory(categoryId, force) { const cacheKey = `cache/category+guild+questions:${categoryId}`; /** @type {CategoryGuildQuestions} */ let category = await this.client.keyv.get(cacheKey); - if (!category) { + if (!category || force) { category = await this.client.prisma.category.findUnique({ include: { guild: true, @@ -45,16 +53,16 @@ module.exports = class TicketManager { }, where: { id: categoryId }, }); - this.client.keyv.set(cacheKey, category, ms('5m')); + await this.client.keyv.set(cacheKey, category, ms('12h')); } return category; } // TODO: update when a ticket is closed or moved async getTotalCount(categoryId) { - const category = this.$.categories[categoryId]; - if (!category) this.$.categories[categoryId] = {}; - let count = this.$.categories[categoryId].total; + const category = this.$count.categories[categoryId]; + if (!category) this.$count.categories[categoryId] = {}; + let count = this.$count.categories[categoryId].total; if (!count) { count = await this.client.prisma.ticket.count({ where: { @@ -62,16 +70,16 @@ module.exports = class TicketManager { open: true, }, }); - this.$.categories[categoryId].total = count; + this.$count.categories[categoryId].total = count; } return count; } // TODO: update when a ticket is closed or moved async getMemberCount(categoryId, memberId) { - const category = this.$.categories[categoryId]; - if (!category) this.$.categories[categoryId] = {}; - let count = this.$.categories[categoryId][memberId]; + const category = this.$count.categories[categoryId]; + if (!category) this.$count.categories[categoryId] = {}; + let count = this.$count.categories[categoryId][memberId]; if (!count) { count = await this.client.prisma.ticket.count({ where: { @@ -80,7 +88,7 @@ module.exports = class TicketManager { open: true, }, }); - this.$.categories[categoryId][memberId] = count; + this.$count.categories[categoryId][memberId] = count; } return count; } @@ -308,17 +316,15 @@ module.exports = class TicketManager { }) { await interaction.deferReply({ ephemeral: true }); - const cacheKey = `cache/category+guild+questions:${categoryId}`; - /** @type {CategoryGuildQuestions} */ - const category = await this.client.keyv.get(cacheKey); + const category = await this.getCategory(categoryId); let answers; if (interaction.isModalSubmit()) { if (action === 'questions') { - answers = category.questions.map(q => ({ + answers = category.questions.filter(q => q.type === 'TEXT').map(q => ({ questionId: q.id, userId: interaction.user.id, - value: interaction.fields.getTextInputValue(q.id) ? cryptr.encrypt(interaction.fields.getTextInputValue(q.id)) : '', + value: interaction.fields.getTextInputValue(q.id) ? encrypt(interaction.fields.getTextInputValue(q.id)) : '', })); if (category.customTopic) topic = interaction.fields.getTextInputValue(category.customTopic); } else if (action === 'topic') { @@ -568,7 +574,7 @@ module.exports = class TicketManager { id: channel.id, number, openingMessageId: sent.id, - topic: topic ? cryptr.encrypt(topic) : null, + topic: topic ? encrypt(topic) : null, }; if (referencesTicketId) data.referencesTicket = { connect: { id: referencesTicketId } }; if (answers) data.questionAnswers = { createMany: { data: answers } }; @@ -587,8 +593,8 @@ module.exports = class TicketManager { try { const ticket = await this.client.prisma.ticket.create({ data }); - this.$.categories[categoryId].total++; - this.$.categories[categoryId][creator.id]++; + this.$count.categories[categoryId].total++; + this.$count.categories[categoryId][creator.id]++; if (category.cooldown) { const cacheKey = `cooldowns/category-member:${category.id}-${ticket.createdById}`; @@ -805,15 +811,170 @@ module.exports = class TicketManager { /** * @param {import("discord.js").ChatInputCommandInteraction|import("discord.js").ButtonInteraction} interaction */ - async requestClose(interaction) { + async beforeRequestClose(interaction) { const ticket = await this.client.prisma.ticket.findUnique({ include: { - category: true, + category: { select: { enableFeedback: true } }, + feedback: { select: { id: true } }, guild: true, }, where: { id: interaction.channel.id }, }); + + if (!ticket) { + await interaction.deferReply({ ephemeral: true }); + const { + errorColour, + locale, + } = await this.client.prisma.guild.findUnique({ + select: { + errorColour: true, + locale: true, + }, + where: { id: interaction.guild.id }, + }); + const getMessage = this.client.i18n.getLocale(locale); + return await interaction.editReply({ + embeds: [ + new ExtendedEmbedBuilder() + .setColor(errorColour) + .setTitle(getMessage('misc.not_ticket.title')) + .setDescription(getMessage('misc.not_ticket.description')), + ], + }); + } + + const getMessage = this.client.i18n.getLocale(ticket.guild.locale); + const staff = await isStaff(interaction.guild, interaction.user.id); + const reason = interaction.options?.getString('reason', false) || null; // ?. because it could be a button interaction) + + if (ticket.createdById !== interaction.user.id && !staff) { + return await interaction.editReply({ + embeds: [ + new ExtendedEmbedBuilder() + .setColor(ticket.guild.errorColour) + .setTitle(getMessage('ticket.close.forbidden.title')) + .setDescription(getMessage('ticket.close.forbidden.description')), + ], + }); + } + + if (ticket.createdById === interaction.user.id && ticket.category.enableFeedback && !ticket.feedback) { + return await interaction.showModal( + new ModalBuilder() + .setCustomId(JSON.stringify({ + action: 'feedback', + reason, + })) + .setTitle(getMessage('modals.feedback.title')) + .setComponents( + new ActionRowBuilder() + .setComponents( + new TextInputBuilder() + .setCustomId('rating') + .setLabel(getMessage('modals.feedback.rating.label')) + .setStyle(TextInputStyle.Short) + .setMaxLength(3) + .setMinLength(1) + .setPlaceholder(getMessage('modals.feedback.rating.placeholder')) + .setRequired(false), + ), + new ActionRowBuilder() + .setComponents( + new TextInputBuilder() + .setCustomId('comment') + .setLabel(getMessage('modals.feedback.comment.label')) + .setStyle(TextInputStyle.Paragraph) + .setMaxLength(1000) + .setMinLength(4) + .setPlaceholder(getMessage('modals.feedback.comment.placeholder')) + .setRequired(false), + ), + ), + ); + + } + + // defer asap + await interaction.deferReply(); + + // if the creator isn't in the guild , close the ticket immediately + // (although leaving should cause the ticket to be closed anyway) + try { + await interaction.guild.members.fetch(ticket.createdById); + } catch { + return this.close(ticket.id, true, reason); + } + + await this.requestClose(interaction, reason); + } + + /** + * @param {import("discord.js").ChatInputCommandInteraction|import("discord.js").ButtonInteraction|import("discord.js").ModalSubmitInteraction} interaction + */ + async requestClose(interaction, reason) { + // interaction could be command, button. or modal + const ticket = await this.client.prisma.ticket.findUnique({ + include: { guild: true }, + where: { id: interaction.channel.id }, + }); const getMessage = this.client.i18n.getLocale(ticket.guild.locale); + const staff = await isStaff(interaction.guild, interaction.user.id); + const closeButtonId = { + action: 'close', + expect: staff ? 'user' : 'staff', + }; + const embed = new ExtendedEmbedBuilder() + .setColor(ticket.guild.primaryColour) + .setTitle(getMessage(`ticket.close.${staff ? 'staff' : 'user'}_request.title`, { requestedBy: interaction.member.displayName })); + + if (staff) { + embed.setDescription( + getMessage('ticket.close.staff_request.description', { requestedBy: interaction.user.toString() }) + + (ticket.guild.archive ? getMessage('ticket.close.staff_request.archived') : ''), + ); + } + + const sent = await interaction.editReply({ + components: [ + new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId(JSON.stringify({ + accepted: true, + ...closeButtonId, + })) + .setStyle(ButtonStyle.Success) + .setEmoji(getMessage('buttons.accept_close_request.emoji')) + .setLabel(getMessage('buttons.accept_close_request.text')), + new ButtonBuilder() + .setCustomId(JSON.stringify({ + accepted: false, + ...closeButtonId, + })) + .setStyle(ButtonStyle.Danger) + .setEmoji(getMessage('buttons.reject_close_request.emoji')) + .setLabel(getMessage('buttons.reject_close_request.text')), + ), + ], + content: staff ? `<@${ticket.createdById}>` : '', // ticket.category.pingRoles.map(r => `<@&${r}>`).join(' ') + embeds: [embed], + }); + + this.$stale.set(ticket.id, { + closeAt: ticket.guild.autoClose ? Date.now() + ticket.guild.autoClose : null, + closedBy: interaction.user.id, // null if set as stale due to inactivity + message: sent, + reason, + staleSince: Date.now(), + }); + + if (ticket.priority && ticket.priority !== 'LOW') { + await this.client.prisma.ticket.update({ + data: { priority: 'LOW' }, + where: { id: ticket.id }, + }); + } } /** @@ -822,10 +983,11 @@ module.exports = class TicketManager { * @param {boolean} skip * @param {string} reason */ - async final(ticketId, skip, reason) { + async close(ticketId, skip, reason) { // TODO: update cache/cat count // TODO: update cache/member count // TODO: set messageCount on ticket + // TODO: pinnedMessages, closedBy, closedAt // delete } }; \ No newline at end of file diff --git a/src/listeners/client/ready.js b/src/listeners/client/ready.js index 26bdf3501..bfb3cd4e0 100644 --- a/src/listeners/client/ready.js +++ b/src/listeners/client/ready.js @@ -108,5 +108,13 @@ module.exports = class extends Listener { send(); setInterval(() => send(), ms('12h')); } + + setInterval(() => { + // TODO: check lastMessageAt and set stale + + for (const [ticketId, $] of client.tickets.$stale) { + // ⌛ + } + }, ms('5m')); } }; diff --git a/src/modals/feedback.js b/src/modals/feedback.js index 74c19ac74..cb68190f8 100644 --- a/src/modals/feedback.js +++ b/src/modals/feedback.js @@ -8,5 +8,24 @@ module.exports = class FeedbackModal extends Modal { }); } - async run(id, interaction) { } + async run(id, interaction) { + /** @type {import("client")} */ + const client = this.client; + + await interaction.deferReply(); + await client.prisma.ticket.update({ + data: { + feedback: { + create: { + comment: interaction.fields.getTextInputValue('comment'), + guild: { connect: { id: interaction.guild.id } }, + rating: parseInt(interaction.fields.getTextInputValue('rating')) || null, + user: { connect: { id: interaction.user.id } }, + }, + }, + }, + where: { id: interaction.channel.id }, + }); + await client.tickets.requestClose(interaction, id.reason); + } }; \ No newline at end of file