diff --git a/.gitignore b/.gitignore index db3f820..bf2418f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,7 @@ prisma/ # files *.env* -*.db -*.db-journal +*.db* *.log *-lock.* user/config.yml diff --git a/package.json b/package.json index 7c57e2e..da125ba 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@fastify/http-proxy": "^8.4.0", "@fastify/jwt": "^5.0.1", "@fastify/oauth2": "^5.1.0", - "@prisma/client": "^4.8.1", + "@prisma/client": "^4.9.0", "cryptr": "^6.1.0", "discord.js": "^14.7.1", "dotenv": "^16.0.3", @@ -60,7 +60,7 @@ "node-emoji": "^1.11.0", "object-diffy": "^1.0.4", "pad": "^3.2.0", - "prisma": "^4.8.1", + "prisma": "^4.9.0", "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 7893884..ff1a61e 100644 --- a/src/buttons/close.js +++ b/src/buttons/close.js @@ -18,25 +18,32 @@ module.exports = class CloseButton extends Button { /** @type {import("client")} */ const client = this.client; - // the close button on th opening message, the same as using /close if (id.accepted === undefined) { + // the close button on the opening message, the same as using /close await client.tickets.beforeRequestClose(interaction); } else { - await interaction.deferReply(); - const ticket = await client.prisma.ticket.findUnique({ - include: { - category: true, - guild: true, - }, - where: { id: interaction.channel.id }, - }); + const ticket = await client.tickets.getTicket(interaction.channel.id); const getMessage = client.i18n.getLocale(ticket.guild.locale); const staff = await isStaff(interaction.guild, interaction.user.id); if (id.expect === 'staff' && !staff) { - return; // TODO: please wait for staff to close the ticket - } else if (id.expect === 'user' && staff) { - return; // TODO: please wait for the user to respond + return await interaction.reply({ + embeds: [ + new ExtendedEmbedBuilder() + .setColor(ticket.guild.errorColour) + .setDescription(getMessage('ticket.close.wait_for_staff')), + ], + ephemeral: true, + }); + } else if (id.expect === 'user' && interaction.user.id !== ticket.createdById) { + return await interaction.reply({ + embeds: [ + new ExtendedEmbedBuilder() + .setColor(ticket.guild.errorColour) + .setDescription(getMessage('ticket.close.wait_for_user')), + ], + ephemeral: true, + }); } else { if (id.accepted) { if ( @@ -46,25 +53,31 @@ module.exports = class CloseButton extends Button { ) { return await interaction.showModal(client.tickets.buildFeedbackModal(ticket.guild.locale, { next: 'acceptClose' })); } else { + await interaction.deferReply(); await client.tickets.acceptClose(interaction); } } else { + // TODO: reply if (client.tickets.$stale.has(ticket.id)) { - await interaction.channel.messages.edit( - client.tickets.$stale.get(ticket.id).message.id, - { - components: [], - embeds: [ - new ExtendedEmbedBuilder({ - iconURL: interaction.guild.iconURL(), - text: ticket.guild.footer, - }) - .setColor(ticket.guild.errorColour) - .setDescription(getMessage('ticket.close.rejected', { user: interaction.user.toString() })), - ], - }, - ); - client.tickets.$stale.delete(ticket.id); + try { + await interaction.channel.messages.edit( + client.tickets.$stale.get(ticket.id).message.id, + { + components: [], + embeds: [ + new ExtendedEmbedBuilder({ + iconURL: interaction.guild.iconURL(), + text: ticket.guild.footer, + }) + .setColor(ticket.guild.errorColour) + .setDescription(getMessage('ticket.close.rejected', { user: interaction.user.toString() })) + .setFooter({ text: null }), + ], + }, + ); + } finally { // this should run regardless of whatever happens above + client.tickets.$stale.delete(ticket.id); + } } } } diff --git a/src/client.js b/src/client.js index b34f4ae..82122fb 100644 --- a/src/client.js +++ b/src/client.js @@ -52,7 +52,12 @@ module.exports = class Client extends FrameworkClient { async login(token) { /** @type {PrismaClient} */ this.prisma = new PrismaClient(); - if (process.env.DB_PROVIDER === 'sqlite') this.prisma.$use(sqliteMiddleware); + if (process.env.DB_PROVIDER === 'sqlite') { + this.prisma.$use(sqliteMiddleware); + // make sqlite faster (https://www.sqlite.org/wal.html), + // and the missing parentheses are not a mistake, `$queryRaw` is a tagged template literal + this.log.debug(await this.prisma.$queryRaw`PRAGMA journal_mode=WAL;`); + } this.keyv = new Keyv(); return super.login(token); } diff --git a/src/commands/slash/move.js b/src/commands/slash/move.js index 335699c..b5437a2 100644 --- a/src/commands/slash/move.js +++ b/src/commands/slash/move.js @@ -91,10 +91,10 @@ module.exports = class MoveSlashCommand extends SlashCommand { $oldCategory.total--; $oldCategory[ticket.createdById]--; - if (!$newCategory.total) $newCategory.total = 0; + $newCategory.total ||= 0; $newCategory.total++; - if (!$newCategory[ticket.createdById]) $newCategory[ticket.createdById] = 0; + $newCategory[ticket.createdById] ||= 0; $newCategory[ticket.createdById]++; await interaction.channel.setParent(discordCategory, { diff --git a/src/i18n/en-GB.yml b/src/i18n/en-GB.yml index 20783f6..1e478f5 100644 --- a/src/i18n/en-GB.yml +++ b/src/i18n/en-GB.yml @@ -349,6 +349,9 @@ ticket: no_value: "*No response*" claimed: 🙌 {user} has claimed this ticket. close: + closed: + description: This channel will be deleted in a few seconds... + title: ✅ Ticket closed forbidden: description: You don't have permission to close this ticket. title: ❌ Error @@ -363,6 +366,8 @@ ticket: title: ❓ Can this ticket be closed? user_request: title: ❓ {requestedBy} wants to close this ticket + wait_for_staff: ✋ Please wait for staff to close this ticket. + wait_for_user: ✋ Please wait for the user to respond. created: description: "Your ticket channel has been created: {channel}." title: ✅ Ticket created diff --git a/src/lib/tickets/archiver.js b/src/lib/tickets/archiver.js index f57df97..04afac4 100644 --- a/src/lib/tickets/archiver.js +++ b/src/lib/tickets/archiver.js @@ -2,7 +2,7 @@ const Cryptr = require('cryptr'); const { encrypt } = new Cryptr(process.env.ENCRYPTION_KEY); /** - * Returns highest (roles.highest) hoisted role , or everyone + * Returns highest (roles.highest) hoisted role, or everyone * @param {import("discord.js").GuildMember} member * @returns {import("discord.js").Role} */ @@ -121,7 +121,7 @@ module.exports = class TicketArchiver { id: message.id, }; - await this.client.prisma.ticket.update({ + return await this.client.prisma.ticket.update({ data: { archivedChannels: { upsert: channels.map(channel => { diff --git a/src/lib/tickets/manager.js b/src/lib/tickets/manager.js index a716368..44e97f4 100644 --- a/src/lib/tickets/manager.js +++ b/src/lib/tickets/manager.js @@ -26,6 +26,14 @@ const { encrypt } = new Cryptr(process.env.ENCRYPTION_KEY); * {guild: import('@prisma/client').Guild} & * {questions: import('@prisma/client').Question[]}} CategoryGuildQuestions */ + +/** + * @typedef {import('@prisma/client').Ticket & + * {category: import('@prisma/client').Category} & + * {feedback: import('@prisma/client').Feedback} & + * {guild: import('@prisma/client').Guild}} TicketCategoryFeedbackGuild + */ + module.exports = class TicketManager { constructor(client) { /** @type {import("client")} */ @@ -58,10 +66,32 @@ module.exports = class TicketManager { return category; } - // TODO: update when a ticket is closed or moved + /** + * Retrieve cached ticket data for the closing sequence + * @param {string} ticketId the ticket ID + * @param {boolean} force bypass & update the cache? + * @returns {Promise} + */ + async getTicket(ticketId, force) { + const cacheKey = `cache/ticket+category+feedback+guild:${ticketId}`; + /** @type {TicketCategoryFeedbackGuild} */ + let ticket = await this.client.keyv.get(cacheKey); + if (!ticket || force) { + ticket = await this.client.prisma.ticket.findUnique({ + include: { + category: true, + feedback: true, + guild: true, + }, + where: { id: ticketId }, + }); + await this.client.keyv.set(cacheKey, ticket, ms('3m')); + } + return ticket; + } + async getTotalCount(categoryId) { - const category = this.$count.categories[categoryId]; - if (!category) this.$count.categories[categoryId] = {}; + this.$count.categories[categoryId] ||= {}; let count = this.$count.categories[categoryId].total; if (!count) { count = await this.client.prisma.ticket.count({ @@ -75,10 +105,8 @@ module.exports = class TicketManager { return count; } - // TODO: update when a ticket is closed or moved async getMemberCount(categoryId, memberId) { - const category = this.$count.categories[categoryId]; - if (!category) this.$count.categories[categoryId] = {}; + this.$count.categories[categoryId] ||= {}; let count = this.$count.categories[categoryId][memberId]; if (!count) { count = await this.client.prisma.ticket.count({ @@ -314,9 +342,10 @@ module.exports = class TicketManager { async postQuestions({ action, categoryId, interaction, topic, referencesMessage, referencesTicketId, }) { - await interaction.deferReply({ ephemeral: true }); - - const category = await this.getCategory(categoryId); + const [, category] = await Promise.all([ + interaction.deferReply({ ephemeral: true }), + this.getCategory(categoryId), + ]); let answers; if (interaction.isModalSubmit()) { @@ -604,11 +633,12 @@ module.exports = class TicketManager { } if (category.guild.archive && message) { - let row = await this.client.prisma.archivedMessage.findUnique({ where: { id: message.id } }); - if (!row) row = await this.archiver.saveMessage(ticket.id, message, true); - if (row) { + if ( + await this.client.prisma.archivedMessage.findUnique({ where: { id: message.id } })|| + await this.archiver.saveMessage(ticket.id, message, true) + ) { await this.client.prisma.ticket.update({ - data: { referencesMessageId: row.id }, + data: { referencesMessageId: message.id }, where: { id: ticket.id }, }); } @@ -657,21 +687,21 @@ module.exports = class TicketManager { }); const getMessage = this.client.i18n.getLocale(ticket.guild.locale); - await interaction.channel.permissionOverwrites.edit(interaction.user, { 'ViewChannel': true }, `Ticket claimed by ${interaction.user.tag}`); - - for (const role of ticket.category.staffRoles) await interaction.channel.permissionOverwrites.edit(role, { 'ViewChannel': false }, `Ticket claimed by ${interaction.user.tag}`); - - await this.client.prisma.ticket.update({ - data: { - claimedBy: { - connectOrCreate: { - create: { id: interaction.user.id }, - where: { id: interaction.user.id }, + await Promise.all([ + interaction.channel.permissionOverwrites.edit(interaction.user, { 'ViewChannel': true }, `Ticket claimed by ${interaction.user.tag}`), + ...ticket.category.staffRoles.map(role => interaction.channel.permissionOverwrites.edit(role, { 'ViewChannel': false }, `Ticket claimed by ${interaction.user.tag}`)), + this.client.prisma.ticket.update({ + data: { + claimedBy: { + connectOrCreate: { + create: { id: interaction.user.id }, + where: { id: interaction.user.id }, + }, }, }, - }, - where: { id: interaction.channel.id }, - }); + where: { id: interaction.channel.id }, + }), + ]); const openingMessage = await interaction.channel.messages.fetch(ticket.openingMessageId); @@ -735,6 +765,7 @@ module.exports = class TicketManager { async release(interaction) { const ticket = await this.client.prisma.ticket.findUnique({ include: { + _count: { select: { questionAnswers: true } }, category: true, guild: true, }, @@ -742,14 +773,14 @@ module.exports = class TicketManager { }); const getMessage = this.client.i18n.getLocale(ticket.guild.locale); - await interaction.channel.permissionOverwrites.delete(interaction.user, `Ticket released by ${interaction.user.tag}`); - - for (const role of ticket.category.staffRoles) await interaction.channel.permissionOverwrites.edit(role, { 'ViewChannel': true }, `Ticket released by ${interaction.user.tag}`); - - await this.client.prisma.ticket.update({ - data: { claimedBy: { disconnect: true } }, - where: { id: interaction.channel.id }, - }); + await Promise.all([ + interaction.channel.permissionOverwrites.delete(interaction.user, `Ticket released by ${interaction.user.tag}`), + ...ticket.category.staffRoles.map(role => interaction.channel.permissionOverwrites.edit(role, { 'ViewChannel': true }, `Ticket released by ${interaction.user.tag}`)), + this.client.prisma.ticket.update({ + data: { claimedBy: { disconnect: true } }, + where: { id: interaction.channel.id }, + }), + ]); const openingMessage = await interaction.channel.messages.fetch(ticket.openingMessageId); @@ -846,19 +877,12 @@ module.exports = class TicketManager { * @param {import("discord.js").ChatInputCommandInteraction|import("discord.js").ButtonInteraction} interaction */ async beforeRequestClose(interaction) { - const ticket = await this.client.prisma.ticket.findUnique({ - include: { - category: { select: { enableFeedback: true } }, - feedback: true, - guild: true, - }, - where: { id: interaction.channel.id }, - }); - + const ticket = await this.getTicket(interaction.channel.id); if (!ticket) { await interaction.deferReply({ ephemeral: true }); const { errorColour, + footer, locale, } = await this.client.prisma.guild.findUnique({ select: { @@ -872,7 +896,7 @@ module.exports = class TicketManager { embeds: [ new ExtendedEmbedBuilder({ iconURL: interaction.guild.iconURL(), - text: ticket.guild.footer, + text: footer, }) .setColor(errorColour) .setTitle(getMessage('misc.not_ticket.title')) @@ -903,7 +927,7 @@ module.exports = class TicketManager { ) { return await interaction.showModal(this.buildFeedbackModal(ticket.guild.locale, { next: 'requestClose', - reason, // known issue: a reason longer than a few words will cause an error due to 100 character ID limit + reason, // known issue: a reason longer than a few words will cause an error due to 100 character custom_id limit })); } @@ -920,7 +944,7 @@ module.exports = class TicketManager { return this.finallyClose(ticket.id, { reason }); } - await this.requestClose(interaction, reason); + this.requestClose(interaction, reason); } /** @@ -929,12 +953,9 @@ module.exports = class TicketManager { */ 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 ticket = await this.getTicket(interaction.channel.id); const getMessage = this.client.i18n.getLocale(ticket.guild.locale); - const staff = await isStaff(interaction.guild, interaction.user.id); + const staff = interaction.user.id !== ticket.createdById && await isStaff(interaction.guild, interaction.user.id); const closeButtonId = { action: 'close', expect: staff ? 'user' : 'staff', @@ -999,20 +1020,71 @@ module.exports = class TicketManager { /** * @param {import("discord.js").ChatInputCommandInteraction|import("discord.js").ButtonInteraction|import("discord.js").ModalSubmitInteraction} interaction */ - async acceptClose(interaction) {} + async acceptClose(interaction) { + const ticket = await this.getTicket(interaction.channel.id); + const $ticket = this.$stale.get(interaction.channel.id); + const getMessage = this.client.i18n.getLocale(ticket.guild.locale); + await interaction.editReply({ + embeds: [ + new ExtendedEmbedBuilder({ + iconURL: interaction.guild.iconURL(), + text: ticket.guild.footer, + }) + .setColor(ticket.guild.successColour) + .setTitle(getMessage('ticket.close.closed.title')) + .setDescription(getMessage('ticket.close.closed.description')), + ], + }); + await new Promise(resolve => setTimeout(resolve, 5000)); + await this.finallyClose(interaction.channel.id, $ticket); + } /** * close a ticket * @param {string} ticketId */ async finallyClose(ticketId, { - closedBy, - reason, + closedBy = null, + reason = null, }) { - // TODO: update cache/cat count - // TODO: update cache/member count - // TODO: set messageCount on ticket - // TODO: pinnedMessages, closedBy, closedAt - // delete + const ticket = await this.getTicket(ticketId); + this.$count.categories[ticket.categoryId].total -= 1; + this.$count.categories[ticket.categoryId][ticket.createdById] -= 1; + + const { _count: { archivedMessages } } = await this.client.prisma.ticket.findUnique({ + select: { _count: { select: { archivedMessages: true } } }, + where: { id: ticket.id }, + }); + + /** @type {import("@prisma/client").Ticket} */ + const data = { + closedAt: new Date(), + closedBy: closedBy && { + connectOrCreate: { + create: { id: closedBy }, + where: { id: closedBy }, + }, + } || undefined, // Prisma wants undefined not null because it is a relation + closedReason: reason && encrypt(reason), + messageCount: archivedMessages, + }; + + /** @type {import("discord.js").TextChannel} */ + const channel = this.client.channels.cache.get(ticketId); + if (channel) { + const pinned = await channel.messages.fetchPinned(); + data.pinnedMessageIds = pinned.keys(); + } + + await this.client.prisma.ticket.update({ + data, + where: { id: ticket.id }, + }); + + + if (channel?.deletable) { + const member = closedBy ? channel.guild.members.cache.get(closedBy) : null; + await channel.delete('Ticket closed' + (member ? ` by ${member.displayName}` : '') + reason ? `: ${reason}` : ''); + } } }; \ No newline at end of file diff --git a/src/listeners/client/messageCreate.js b/src/listeners/client/messageCreate.js index c55ced3..46386e4 100644 --- a/src/listeners/client/messageCreate.js +++ b/src/listeners/client/messageCreate.js @@ -180,7 +180,7 @@ module.exports = class extends Listener { }); } } else { - const settings = await client.prisma.guild.findUnique({ where: { id:message.guild.id } }); + const settings = await client.prisma.guild.findUnique({ where: { id: message.guild.id } }); let ticket = await client.prisma.ticket.findUnique({ where: { id: message.channel.id } }); if (ticket) { diff --git a/src/listeners/client/messageUpdate.js b/src/listeners/client/messageUpdate.js index db3b78d..98ee177 100644 --- a/src/listeners/client/messageUpdate.js +++ b/src/listeners/client/messageUpdate.js @@ -47,6 +47,8 @@ module.exports = class extends Listener { } } + if (newMessage.author.id === client.user.id) return; + await logMessageEvent(this.client, { action: 'update', diff: { diff --git a/src/listeners/client/ready.js b/src/listeners/client/ready.js index bfb3cd4..f19baba 100644 --- a/src/listeners/client/ready.js +++ b/src/listeners/client/ready.js @@ -111,6 +111,14 @@ module.exports = class extends Listener { setInterval(() => { // TODO: check lastMessageAt and set stale + // this.$stale.set(ticket.id, { + // closeAt: ticket.guild.autoClose ? Date.now() + ticket.guild.autoClose : null, + // closedBy: null, // null if set as stale due to inactivity + // message: sent, + // messages: 0, + // reason: 'inactivity', + // staleSince: Date.now(), + // }); for (const [ticketId, $] of client.tickets.$stale) { // ⌛ diff --git a/src/modals/feedback.js b/src/modals/feedback.js index dcc355a..05afa42 100644 --- a/src/modals/feedback.js +++ b/src/modals/feedback.js @@ -1,5 +1,8 @@ const { Modal } = require('@eartharoid/dbf'); const ExtendedEmbedBuilder = require('../lib/embed'); +const Cryptr = require('cryptr'); +const { encrypt } = new Cryptr(process.env.ENCRYPTION_KEY); + module.exports = class FeedbackModal extends Modal { constructor(client, options) { super(client, { @@ -19,13 +22,14 @@ module.exports = class FeedbackModal extends Modal { await interaction.deferReply(); const comment = interaction.fields.getTextInputValue('comment'); - const rating = parseInt(interaction.fields.getTextInputValue('rating')) || null; + let rating = parseInt(interaction.fields.getTextInputValue('rating')) || null; // any integer, or null if NaN + rating = Math.min(Math.max(rating, 1), 5); // clamp between 1 and 5 (0 and null become 1, 6 becomes 5) const ticket = await client.prisma.ticket.update({ data: { feedback: { create: { - comment, + comment: comment?.length > 0 ? encrypt(comment) : null, guild: { connect: { id: interaction.guild.id } }, rating, user: { connect: { id: interaction.user.id } }, @@ -36,6 +40,7 @@ module.exports = class FeedbackModal extends Modal { where: { id: interaction.channel.id }, }); + if (id.next === 'requestClose') await client.tickets.requestClose(interaction, id.reason); else if (id.next === 'acceptClose') await client.tickets.acceptClose(interaction); diff --git a/src/routes/api/admin/guilds/[guild]/categories/[category]/index.js b/src/routes/api/admin/guilds/[guild]/categories/[category]/index.js index 3493c1e..2aeae9b 100644 --- a/src/routes/api/admin/guilds/[guild]/categories/[category]/index.js +++ b/src/routes/api/admin/guilds/[guild]/categories/[category]/index.js @@ -140,6 +140,8 @@ module.exports.patch = fastify => ({ where: { id: categoryId }, }); + // update caches + await client.tickets.getCategory(categoryId, true); await updateStaffRoles(guild); logAdminEvent(client, { diff --git a/src/routes/api/admin/guilds/[guild]/categories/index.js b/src/routes/api/admin/guilds/[guild]/categories/index.js index f92072a..e7d1c38 100644 --- a/src/routes/api/admin/guilds/[guild]/categories/index.js +++ b/src/routes/api/admin/guilds/[guild]/categories/index.js @@ -94,6 +94,8 @@ module.exports.post = fastify => ({ }, }); + // update caches + await client.tickets.getCategory(category.id, true); await updateStaffRoles(guild); logAdminEvent(client, { diff --git a/src/schemas/settings.js b/src/schemas/settings.js index e4af137..e1081b7 100644 --- a/src/schemas/settings.js +++ b/src/schemas/settings.js @@ -1,6 +1,6 @@ module.exports = joi.object({ archive: joi.boolean().optional(), - autoClose: joi.number().min(3600000).optional(), + autoClose: joi.number().min(3_600_000).optional(), autoTag: [joi.array(), joi.string().valid('ticket', '!ticket', 'all')].optional(), blocklist: joi.array().optional(), createdAt: joi.string().optional(), @@ -9,7 +9,7 @@ module.exports = joi.object({ id: joi.string().optional(), logChannel: joi.string().optional(), primaryColour: joi.string().optional(), - staleAfter: joi.number().min(60000).optional(), + staleAfter: joi.number().min(60_000).optional(), successColour: joi.string().optional(), workingHours: joi.array().length(8).items( joi.string(),