From a190c1ac277d59efa418856608ce1fe514318a45 Mon Sep 17 00:00:00 2001 From: Isaac Date: Thu, 11 Aug 2022 22:59:50 +0100 Subject: [PATCH] Finish pre-open checks, reduce database reads with more caching --- README.md | 16 ++- src/i18n/en-GB.yml | 5 + src/lib/tickets/manager.js | 237 ++++++++++++++++++++++------------ src/listeners/client/ready.js | 75 +++++++++-- 4 files changed, 237 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index 63965c1..897d855 100644 --- a/README.md +++ b/README.md @@ -30,4 +30,18 @@ creation requires an interaction: - message:create(staff) -> category? -> DM (channel fallback) button -> topic or questions -> create - DM -> guild? -> category? -> topic or questions -> create - panel(interaction) -> topic or questions -> create -- ~~panel(message) -> DM (channel fallback) button -> topic or questions -> create~~ \ No newline at end of file +- ~~panel(message) -> DM (channel fallback) button -> topic or questions -> create~~ + +> **Note** +> +> test + +> **Warning** +> +> test + + diff --git a/src/i18n/en-GB.yml b/src/i18n/en-GB.yml index 966afd3..4009a7d 100644 --- a/src/i18n/en-GB.yml +++ b/src/i18n/en-GB.yml @@ -186,6 +186,11 @@ misc: cooldown: description: Please wait {time} before creating another ticket in this category. title: ❌ Please wait + error: + description: Sorry, an unexpected error occurred. + fields: + identifier: Identifier + title: ⚠️ Something's wrong member_limit: description: - Please use your existing ticket or close it before creating another. diff --git a/src/lib/tickets/manager.js b/src/lib/tickets/manager.js index fa21080..5c43597 100644 --- a/src/lib/tickets/manager.js +++ b/src/lib/tickets/manager.js @@ -3,6 +3,7 @@ const { ActionRowBuilder, ButtonBuilder, ButtonStyle, + inlineCode, ModalBuilder, SelectMenuBuilder, SelectMenuOptionBuilder, @@ -15,12 +16,73 @@ const ExtendedEmbedBuilder = require('../embed'); const { logTicketEvent } = require('../logging'); /** - * @typedef {import('@prisma/client').Category & {guild: import('@prisma/client').Guild} & {questions: import('@prisma/client').Question[]}} CategoryGuildQuestions + * @typedef {import('@prisma/client').Category & + * {guild: import('@prisma/client').Guild} & + * {questions: import('@prisma/client').Question[]}} CategoryGuildQuestions */ module.exports = class TicketManager { constructor(client) { /** @type {import("client")} */ this.client = client; + + this.$ = { categories: {} }; + } + + async getCategory(categoryId) { + const cacheKey = `cache/category+guild+questions:${categoryId}`; + /** @type {CategoryGuildQuestions} */ + let category = await this.client.keyv.get(cacheKey); + if (!category) { + category = await this.client.prisma.category.findUnique({ + include: { + guild: true, + questions: { orderBy: { order: 'asc' } }, + }, + where: { id: categoryId }, + }); + this.client.keyv.set(cacheKey, category, ms('5m')); + } + 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; + if (!count) { + count = await this.client.prisma.ticket.count({ + where: { + categoryId, + open: true, + }, + }); + this.$.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]; + if (!count) { + count = await this.client.prisma.ticket.count({ + where: { + categoryId: categoryId, + createdById: memberId, + open: true, + }, + }); + this.$.categories[categoryId][memberId] = count; + } + return count; + } + + async getCooldown(categoryId, memberId) { + const cacheKey = `cooldowns/category-member:${categoryId}-${memberId}`; + return await this.client.keyv.get(cacheKey); } /** @@ -33,42 +95,31 @@ module.exports = class TicketManager { categoryId, interaction, topic, referencesMessage, referencesTicket, }) { categoryId = Number(categoryId); - const cacheKey = `cache/category+guild+questions:${categoryId}`; - /** @type {CategoryGuildQuestions} */ - let category = await this.client.keyv.get(cacheKey); + const category = await this.getCategory(categoryId); + if (!category) { - category = await this.client.prisma.category.findUnique({ - include: { - guild: true, - questions: { orderBy: { order: 'asc' } }, - }, - where: { id: Number(categoryId) }, - }); - if (!category) { - let settings; - if (interaction.guild) { - settings = await this.client.prisma.guild.findUnique({ where: { id: interaction.guild.id } }); - } else { - settings = { - errorColour: 'Red', - locale: 'en-GB', - }; - } - const getMessage = this.client.i18n.getLocale(settings.locale); - return await interaction.reply({ - embeds: [ - new ExtendedEmbedBuilder({ - iconURL: interaction.guild?.iconURL(), - text: settings.footer, - }) - .setColor(settings.errorColour) - .setTitle(getMessage('misc.unknown_category.title')) - .setDescription(getMessage('misc.unknown_category.description')), - ], - ephemeral: true, - }); + let settings; + if (interaction.guild) { + settings = await this.client.prisma.guild.findUnique({ where: { id: interaction.guild.id } }); + } else { + settings = { + errorColour: 'Red', + locale: 'en-GB', + }; } - this.client.keyv.set(cacheKey, category, ms('5m')); + const getMessage = this.client.i18n.getLocale(settings.locale); + return await interaction.reply({ + embeds: [ + new ExtendedEmbedBuilder({ + iconURL: interaction.guild?.iconURL(), + text: settings.footer, + }) + .setColor(settings.errorColour) + .setTitle(getMessage('misc.unknown_category.title')) + .setDescription(getMessage('misc.unknown_category.description')), + ], + ephemeral: true, + }); } const getMessage = this.client.i18n.getLocale(category.guild.locale); @@ -122,23 +173,10 @@ module.exports = class TicketManager { const discordCategory = guild.channels.cache.get(category.discordCategory); if (discordCategory.children.cache.size === 50) return await sendError('category_full'); - // TODO: store locally and sync regularly so this isn't done during an interaction? - const totalCount = await this.client.prisma.ticket.count({ - where: { - categoryId: category.id, - open: true, - }, - }); + const totalCount = await this.getTotalCount(category.id); if (totalCount >= category.totalLimit) return await sendError('category_full'); - const memberCount = await this.client.prisma.ticket.count({ - where: { - categoryId: category.id, - createdById: interaction.user.id, - open: true, - }, - }); - + const memberCount = await this.getMemberCount(category.id, interaction.user.id); if (memberCount >= category.memberLimit) { return await interaction.reply({ embeds: [ @@ -154,17 +192,33 @@ module.exports = class TicketManager { }); } - const lastTicket = await this.client.prisma.ticket.findFirst({ - orderBy: [{ closedAt: 'desc' }], - select: { closedAt: true }, - where: { - categoryId: category.id, - createdById: interaction.user.id, - open: false, - }, - }); + // const lastTicket = await this.client.prisma.ticket.findFirst({ + // orderBy: [{ closedAt: 'desc' }], + // select: { closedAt: true }, + // where: { + // categoryId: category.id, + // createdById: interaction.user.id, + // open: false, + // }, + // }); - if (Date.now() - lastTicket.closedAt < category.cooldown) { + // if (Date.now() - lastTicket.closedAt < category.cooldown) { + // return await interaction.reply({ + // embeds: [ + // new ExtendedEmbedBuilder({ + // iconURL: interaction.guild.iconURL(), + // text: category.guild.footer, + // }) + // .setColor(category.guild.errorColour) + // .setTitle(getMessage('misc.cooldown.title')) + // .setDescription(getMessage('misc.cooldown.description', { time: ms(category.cooldown - (Date.now() - lastTicket.closedAt)) })), + // ], + // ephemeral: true, + // }); + // } + + const cooldown = await this.getCooldown(category.id, interaction.member.id); + if (cooldown) { return await interaction.reply({ embeds: [ new ExtendedEmbedBuilder({ @@ -173,7 +227,7 @@ module.exports = class TicketManager { }) .setColor(category.guild.errorColour) .setTitle(getMessage('misc.cooldown.title')) - .setDescription(getMessage('misc.cooldown.description', { time: ms(category.cooldown - (Date.now() - lastTicket.closedAt)) })), + .setDescription(getMessage('misc.cooldown.description', { time: ms(cooldown - Date.now()) })), ], ephemeral: true, }); @@ -356,12 +410,6 @@ module.exports = class TicketManager { })), ), ); - // embeds[0].setFields( - // category.questions.map(q => ({ - // name: q.label, - // value: interaction.fields.getTextInputValue(q.id) || getMessage('ticket.answers.no_value'), - // })), - // ); } else if (topic) { embeds.push( new ExtendedEmbedBuilder() @@ -371,10 +419,6 @@ module.exports = class TicketManager { value: topic, }), ); - // embeds[0].setFields({ - // name: getMessage('ticket.opening_message.fields.topic'), - // value: topic, - // }); } if (category.guild.footer) { @@ -455,7 +499,7 @@ module.exports = class TicketManager { 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 (answers) data.questionAnswers = { createMany: { data: answers } }; - interaction.editReply({ + await interaction.editReply({ components: [], embeds: [ new ExtendedEmbedBuilder({ @@ -467,14 +511,45 @@ module.exports = class TicketManager { .setDescription(getMessage('ticket.created.description', { channel: channel.toString() })), ], }); - const ticket = await this.client.prisma.ticket.create({ data }); - logTicketEvent(this.client, { - action: 'create', - target: { - id: ticket.id, - name: channel.toString(), - }, - userId: interaction.user.id, - }); + + try { + const ticket = await this.client.prisma.ticket.create({ data }); + this.$.categories[categoryId].total++; + this.$.categories[categoryId][creator.id]++; + + if (category.cooldown) { + const cacheKey = `cooldowns/category-member:${category.id}-${ticket.createdById}`; + const expiresAt = ticket.createdAt.getTime() + category.cooldown; + const TTL = category.cooldown; + await this.client.keyv.set(cacheKey, expiresAt, TTL); + } + + logTicketEvent(this.client, { + action: 'create', + target: { + id: ticket.id, + name: channel.toString(), + }, + userId: interaction.user.id, + }); + } catch (error) { + const ref = require('crypto').randomUUID(); + this.client.log.warn.tickets('An error occurred whilst creating ticket', channel.id); + this.client.log.error.tickets(ref); + this.client.log.error.tickets(error); + await interaction.editReply({ + components: [], + embeds: [ + new ExtendedEmbedBuilder() + .setColor('Orange') + .setTitle(getMessage('misc.error.title')) + .setDescription(getMessage('misc.error.description')) + .addFields({ + name: getMessage('misc.error.fields.identifier'), + value: inlineCode(ref), + }), + ], + }); + } } }; \ No newline at end of file diff --git a/src/listeners/client/ready.js b/src/listeners/client/ready.js index 5575f68..cd80e1d 100644 --- a/src/listeners/client/ready.js +++ b/src/listeners/client/ready.js @@ -11,18 +11,66 @@ module.exports = class extends Listener { }); } - run() { - // process.title = `"[Discord Tickets] ${this.client.user.tag}"`; // too long and gets cut off - process.title = 'tickets'; - this.client.log.success('Connected to Discord as "%s"', this.client.user.tag); + async run() { + /** @type {import("client")} */ + const client = this.client; + // process.title = `"[Discord Tickets] ${client.user.tag}"`; // too long and gets cut off + process.title = 'tickets'; + client.log.success('Connected to Discord as "%s"', client.user.tag); + + // load total number of open tickets + const categories = await client.prisma.category.findMany({ + select: { + cooldown: true, + id: true, + tickets: { + select: { createdById: true }, + where: { open: true }, + }, + }, + }); + let ticketCount = 0; + let cooldowns = 0; + for (const category of categories) { + ticketCount += category.tickets.length; + client.tickets.$.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 (category.cooldown) { + const recent = await client.prisma.ticket.findMany({ + orderBy: { createdAt: 'asc' }, + select: { + createdAt: true, + createdById: true, + }, + where: { + categoryId: category.id, + createdAt: { gt: new Date(Date.now() - category.cooldown) }, + }, + }); + cooldowns += recent.length; + for (const ticket of recent) { + const cacheKey = `cooldowns/category-member:${category.id}-${ticket.createdById}`; + const expiresAt = ticket.createdAt.getTime() + category.cooldown; + const TTL = expiresAt - Date.now(); + await client.keyv.set(cacheKey, expiresAt, TTL); + } + } + } + // 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`); + + // presence/activity let next = 0; const setPresence = async () => { const cacheKey = 'cache/presence'; - let cached = await this.client.keyv.get(cacheKey); - + let cached = await client.keyv.get(cacheKey); if (!cached) { - const tickets = await this.client.prisma.ticket.findMany({ + const tickets = await client.prisma.ticket.findMany({ select: { createdAt: true, firstResponseAt: true, @@ -35,24 +83,23 @@ module.exports = class extends Listener { openTickets: tickets.length - closedTickets.length, totalTickets: tickets.length, }; - await this.client.keyv.set(cacheKey, cached, ms('15m')); + await client.keyv.set(cacheKey, cached, ms('15m')); } - const activity = this.client.config.presence.activities[next]; + const activity = client.config.presence.activities[next]; activity.name = activity.name .replace(/{+avgResolutionTime}+/gi, cached.avgResolutionTime) .replace(/{+avgResponseTime}+/gi, cached.avgResponseTime) .replace(/{+openTickets}+/gi, cached.openTickets) .replace(/{+totalTickets}+/gi, cached.totalTickets); - this.client.user.setPresence({ + client.user.setPresence({ activities: [activity], - status: this.client.config.presence.status, + status: client.config.presence.status, }); next++; - if (next === this.client.config.presence.activities.length) next = 0; - + if (next === client.config.presence.activities.length) next = 0; }; setPresence(); - if (this.client.config.presence.activities.length > 1) setInterval(() => setPresence(), this.client.config.presence.interval * 1000); + if (client.config.presence.activities.length > 1) setInterval(() => setPresence(), client.config.presence.interval * 1000); } };