Finish pre-open checks, reduce database reads with more caching

This commit is contained in:
Isaac 2022-08-11 22:59:50 +01:00
parent c6abefe30d
commit a190c1ac27
No known key found for this signature in database
GPG Key ID: F4EAABEB0FFCC06A
4 changed files with 237 additions and 96 deletions

View File

@ -31,3 +31,17 @@ creation requires an interaction:
- DM -> guild? -> category? -> topic or questions -> create - DM -> guild? -> category? -> topic or questions -> create
- panel(interaction) -> topic or questions -> create - panel(interaction) -> topic or questions -> create
- ~~panel(message) -> DM (channel fallback) button -> topic or questions -> create~~ - ~~panel(message) -> DM (channel fallback) button -> topic or questions -> create~~
> **Note**
>
> test
> **Warning**
>
> test
<!-- <picture>
<source media="(prefers-color-scheme: dark)" srcset="...>
<source media="(prefers-color-scheme: light)" srcset="...">
<img alt="..." src="...">
</picture> -->

View File

@ -186,6 +186,11 @@ misc:
cooldown: cooldown:
description: Please wait {time} before creating another ticket in this category. description: Please wait {time} before creating another ticket in this category.
title: ❌ Please wait title: ❌ Please wait
error:
description: Sorry, an unexpected error occurred.
fields:
identifier: Identifier
title: ⚠️ Something's wrong
member_limit: member_limit:
description: description:
- Please use your existing ticket or close it before creating another. - Please use your existing ticket or close it before creating another.

View File

@ -3,6 +3,7 @@ const {
ActionRowBuilder, ActionRowBuilder,
ButtonBuilder, ButtonBuilder,
ButtonStyle, ButtonStyle,
inlineCode,
ModalBuilder, ModalBuilder,
SelectMenuBuilder, SelectMenuBuilder,
SelectMenuOptionBuilder, SelectMenuOptionBuilder,
@ -15,12 +16,73 @@ const ExtendedEmbedBuilder = require('../embed');
const { logTicketEvent } = require('../logging'); 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 { module.exports = class TicketManager {
constructor(client) { constructor(client) {
/** @type {import("client")} */ /** @type {import("client")} */
this.client = 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, interaction, topic, referencesMessage, referencesTicket,
}) { }) {
categoryId = Number(categoryId); categoryId = Number(categoryId);
const cacheKey = `cache/category+guild+questions:${categoryId}`; const category = await this.getCategory(categoryId);
/** @type {CategoryGuildQuestions} */
let category = await this.client.keyv.get(cacheKey);
if (!category) { if (!category) {
category = await this.client.prisma.category.findUnique({ let settings;
include: { if (interaction.guild) {
guild: true, settings = await this.client.prisma.guild.findUnique({ where: { id: interaction.guild.id } });
questions: { orderBy: { order: 'asc' } }, } else {
}, settings = {
where: { id: Number(categoryId) }, errorColour: 'Red',
}); locale: 'en-GB',
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,
});
} }
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); 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); const discordCategory = guild.channels.cache.get(category.discordCategory);
if (discordCategory.children.cache.size === 50) return await sendError('category_full'); 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.getTotalCount(category.id);
const totalCount = await this.client.prisma.ticket.count({
where: {
categoryId: category.id,
open: true,
},
});
if (totalCount >= category.totalLimit) return await sendError('category_full'); if (totalCount >= category.totalLimit) return await sendError('category_full');
const memberCount = await this.client.prisma.ticket.count({ const memberCount = await this.getMemberCount(category.id, interaction.user.id);
where: {
categoryId: category.id,
createdById: interaction.user.id,
open: true,
},
});
if (memberCount >= category.memberLimit) { if (memberCount >= category.memberLimit) {
return await interaction.reply({ return await interaction.reply({
embeds: [ embeds: [
@ -154,17 +192,33 @@ module.exports = class TicketManager {
}); });
} }
const lastTicket = await this.client.prisma.ticket.findFirst({ // const lastTicket = await this.client.prisma.ticket.findFirst({
orderBy: [{ closedAt: 'desc' }], // orderBy: [{ closedAt: 'desc' }],
select: { closedAt: true }, // select: { closedAt: true },
where: { // where: {
categoryId: category.id, // categoryId: category.id,
createdById: interaction.user.id, // createdById: interaction.user.id,
open: false, // 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({ return await interaction.reply({
embeds: [ embeds: [
new ExtendedEmbedBuilder({ new ExtendedEmbedBuilder({
@ -173,7 +227,7 @@ module.exports = class TicketManager {
}) })
.setColor(category.guild.errorColour) .setColor(category.guild.errorColour)
.setTitle(getMessage('misc.cooldown.title')) .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, 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) { } else if (topic) {
embeds.push( embeds.push(
new ExtendedEmbedBuilder() new ExtendedEmbedBuilder()
@ -371,10 +419,6 @@ module.exports = class TicketManager {
value: topic, value: topic,
}), }),
); );
// embeds[0].setFields({
// name: getMessage('ticket.opening_message.fields.topic'),
// value: topic,
// });
} }
if (category.guild.footer) { if (category.guild.footer) {
@ -455,7 +499,7 @@ module.exports = class TicketManager {
if (referencesMessage) message = this.client.prisma.archivedMessage.findUnique({ where: { id: referencesMessage } }); 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 (message) data.referencesMessage = { connect: { id: referencesMessage } }; // only add if the message has been archived ^^
if (answers) data.questionAnswers = { createMany: { data: answers } }; if (answers) data.questionAnswers = { createMany: { data: answers } };
interaction.editReply({ await interaction.editReply({
components: [], components: [],
embeds: [ embeds: [
new ExtendedEmbedBuilder({ new ExtendedEmbedBuilder({
@ -467,14 +511,45 @@ module.exports = class TicketManager {
.setDescription(getMessage('ticket.created.description', { channel: channel.toString() })), .setDescription(getMessage('ticket.created.description', { channel: channel.toString() })),
], ],
}); });
const ticket = await this.client.prisma.ticket.create({ data });
logTicketEvent(this.client, { try {
action: 'create', const ticket = await this.client.prisma.ticket.create({ data });
target: { this.$.categories[categoryId].total++;
id: ticket.id, this.$.categories[categoryId][creator.id]++;
name: channel.toString(),
}, if (category.cooldown) {
userId: interaction.user.id, 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),
}),
],
});
}
} }
}; };

View File

@ -11,18 +11,66 @@ module.exports = class extends Listener {
}); });
} }
run() { async run() {
// process.title = `"[Discord Tickets] ${this.client.user.tag}"`; // too long and gets cut off /** @type {import("client")} */
process.title = 'tickets'; const client = this.client;
this.client.log.success('Connected to Discord as "%s"', this.client.user.tag);
// 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; let next = 0;
const setPresence = async () => { const setPresence = async () => {
const cacheKey = 'cache/presence'; const cacheKey = 'cache/presence';
let cached = await this.client.keyv.get(cacheKey); let cached = await client.keyv.get(cacheKey);
if (!cached) { if (!cached) {
const tickets = await this.client.prisma.ticket.findMany({ const tickets = await client.prisma.ticket.findMany({
select: { select: {
createdAt: true, createdAt: true,
firstResponseAt: true, firstResponseAt: true,
@ -35,24 +83,23 @@ module.exports = class extends Listener {
openTickets: tickets.length - closedTickets.length, openTickets: tickets.length - closedTickets.length,
totalTickets: tickets.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 activity.name = activity.name
.replace(/{+avgResolutionTime}+/gi, cached.avgResolutionTime) .replace(/{+avgResolutionTime}+/gi, cached.avgResolutionTime)
.replace(/{+avgResponseTime}+/gi, cached.avgResponseTime) .replace(/{+avgResponseTime}+/gi, cached.avgResponseTime)
.replace(/{+openTickets}+/gi, cached.openTickets) .replace(/{+openTickets}+/gi, cached.openTickets)
.replace(/{+totalTickets}+/gi, cached.totalTickets); .replace(/{+totalTickets}+/gi, cached.totalTickets);
this.client.user.setPresence({ client.user.setPresence({
activities: [activity], activities: [activity],
status: this.client.config.presence.status, status: client.config.presence.status,
}); });
next++; next++;
if (next === this.client.config.presence.activities.length) next = 0; if (next === client.config.presence.activities.length) next = 0;
}; };
setPresence(); 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);
} }
}; };