diff --git a/src/autocomplete/ticket.js b/src/autocomplete/ticket.js index 157bcb0..4df4585 100644 --- a/src/autocomplete/ticket.js +++ b/src/autocomplete/ticket.js @@ -1,11 +1,10 @@ /* eslint-disable no-underscore-dangle */ const { Autocompleter } = require('@eartharoid/dbf'); const emoji = require('node-emoji'); -const Cryptr = require('cryptr'); -const { decrypt } = new Cryptr(process.env.ENCRYPTION_KEY); const Keyv = require('keyv'); const ms = require('ms'); const { isStaff } = require('../lib/users'); +const { reusable } = require('../lib/threads'); module.exports = class TicketCompleter extends Autocompleter { constructor(client, options) { @@ -30,6 +29,7 @@ module.exports = class TicketCompleter extends Autocompleter { let tickets = await this.cache.get(cacheKey); if (!tickets) { + const cmd = client.commands.commands.slash.get('transcript'); const { locale } = await client.prisma.guild.findUnique({ select: { locale: true }, where: { id: guildId }, @@ -42,15 +42,25 @@ module.exports = class TicketCompleter extends Autocompleter { open, }, }); - tickets = tickets - .filter(ticket => client.commands.commands.slash.get('transcript').shouldAllowAccess(interaction, ticket)) - .map(ticket => { - const date = new Date(ticket.createdAt).toLocaleString([locale, 'en-GB'], { dateStyle: 'short' }); - const topic = ticket.topic ? '- ' + decrypt(ticket.topic).replace(/\n/g, ' ').substring(0, 50) : ''; - const category = emoji.hasEmoji(ticket.category.emoji) ? emoji.get(ticket.category.emoji) + ' ' + ticket.category.name : ticket.category.name; - ticket._name = `${category} #${ticket.number} (${date}) ${topic}`; - return ticket; - }); + + const worker = await reusable('crypto'); + try { + tickets = await Promise.all( + tickets + .filter(ticket => cmd.shouldAllowAccess(interaction, ticket)) + .map(async ticket => { + const getTopic = async () => (await worker.decrypt(ticket.topic)).replace(/\n/g, ' ').substring(0, 50); + const date = new Date(ticket.createdAt).toLocaleString([locale, 'en-GB'], { dateStyle: 'short' }); + const topic = ticket.topic ? '- ' + (await getTopic()) : ''; + const category = emoji.hasEmoji(ticket.category.emoji) ? emoji.get(ticket.category.emoji) + ' ' + ticket.category.name : ticket.category.name; + ticket._name = `${category} #${ticket.number} (${date}) ${topic}`; + return ticket; + }), + ); + } finally { + await worker.terminate(); + } + this.cache.set(cacheKey, tickets, ms('1m')); } diff --git a/src/buttons/edit.js b/src/buttons/edit.js index 914225c..a757e15 100644 --- a/src/buttons/edit.js +++ b/src/buttons/edit.js @@ -7,9 +7,8 @@ const { TextInputBuilder, TextInputStyle, } = require('discord.js'); +const { reusable } = require('../lib/threads'); const emoji = require('node-emoji'); -const Cryptr = require('cryptr'); -const cryptr = new Cryptr(process.env.ENCRYPTION_KEY); module.exports = class EditButton extends Button { constructor(client, options) { @@ -35,75 +34,87 @@ module.exports = class EditButton extends Button { const getMessage = client.i18n.getLocale(ticket.guild.locale); - if (ticket.questionAnswers.length === 0) { - const field = new TextInputBuilder() - .setCustomId('topic') - .setLabel(getMessage('modals.topic.label')) - .setStyle(TextInputStyle.Paragraph) - .setMaxLength(1000) - .setMinLength(5) - .setPlaceholder(getMessage('modals.topic.placeholder')) - .setRequired(true); - if (ticket.topic) field.setValue(cryptr.decrypt(ticket.topic)); - await interaction.showModal( - new ModalBuilder() - .setCustomId(JSON.stringify({ - action: 'topic', - edit: true, - })) - .setTitle(ticket.category.name) - .setComponents( - new ActionRowBuilder() - .setComponents(field), - ), - ); - } else { - await interaction.showModal( - new ModalBuilder() - .setCustomId(JSON.stringify({ - action: 'questions', - edit: true, - })) - .setTitle(ticket.category.name) - .setComponents( - ticket.questionAnswers - .filter(a => a.question.type === 'TEXT') // TODO: remove this when modals support select menus - .map(a => { - if (a.question.type === 'TEXT') { - const field = new TextInputBuilder() - .setCustomId(String(a.id)) - .setLabel(a.question.label) - .setStyle(a.question.style) - .setMaxLength(Math.min(a.question.maxLength, 1000)) - .setMinLength(a.question.minLength) - .setPlaceholder(a.question.placeholder) - .setRequired(a.question.required); - if (a.value) field.setValue(cryptr.decrypt(a.value)); - else if (a.question.value) field.setValue(a.question.value); - return new ActionRowBuilder().setComponents(field); - } else if (a.question.type === 'MENU') { - return new ActionRowBuilder() - .setComponents( - 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 StringSelectMenuOptionBuilder() - .setValue(String(i)) - .setLabel(o.label); - if (o.description) builder.setDescription(o.description); - if (o.emoji) builder.setEmoji(emoji.hasEmoji(o.emoji) ? emoji.get(o.emoji) : { id: o.emoji }); - return builder; - }), - ), - ); - } - }), - ), - ); + const worker = await reusable('crypto'); + + try { + if (ticket.questionAnswers.length === 0) { + const field = new TextInputBuilder() + .setCustomId('topic') + .setLabel(getMessage('modals.topic.label')) + .setStyle(TextInputStyle.Paragraph) + .setMaxLength(1000) + .setMinLength(5) + .setPlaceholder(getMessage('modals.topic.placeholder')) + .setRequired(true); + if (ticket.topic) field.setValue(await worker.decrypt(ticket.topic)); + await interaction.showModal( + new ModalBuilder() + .setCustomId(JSON.stringify({ + action: 'topic', + edit: true, + })) + .setTitle(ticket.category.name) + .setComponents( + new ActionRowBuilder() + .setComponents(field), + ), + ); + } else { + await interaction.showModal( + new ModalBuilder() + .setCustomId(JSON.stringify({ + action: 'questions', + edit: true, + })) + .setTitle(ticket.category.name) + .setComponents( + await Promise.all( + ticket.questionAnswers + .filter(a => a.question.type === 'TEXT') // TODO: remove this when modals support select menus + .map(async a => { + if (a.question.type === 'TEXT') { + const field = new TextInputBuilder() + .setCustomId(String(a.id)) + .setLabel(a.question.label) + .setStyle(a.question.style) + .setMaxLength(Math.min(a.question.maxLength, 1000)) + .setMinLength(a.question.minLength) + .setPlaceholder(a.question.placeholder) + .setRequired(a.question.required); + if (a.value) field.setValue(await worker.decrypt(a.value)); + else if (a.question.value) field.setValue(a.question.value); + return new ActionRowBuilder().setComponents(field); + } else if (a.question.type === 'MENU') { + return new ActionRowBuilder() + .setComponents( + 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 StringSelectMenuOptionBuilder() + .setValue(String(i)) + .setLabel(o.label); + if (o.description) builder.setDescription(o.description); + if (o.emoji) { + builder.setEmoji(emoji.hasEmoji(o.emoji) + ? emoji.get(o.emoji) + : { id: o.emoji }); + } + return builder; + }), + ), + ); + } + }), + ), + ), + ); + } + } finally { + await worker.terminate(); } } }; diff --git a/src/commands/slash/tickets.js b/src/commands/slash/tickets.js index 625800f..340001a 100644 --- a/src/commands/slash/tickets.js +++ b/src/commands/slash/tickets.js @@ -5,8 +5,7 @@ const { } = require('discord.js'); const { isStaff } = require('../../lib/users'); const ExtendedEmbedBuilder = require('../../lib/embed'); -const Cryptr = require('cryptr'); -const { decrypt } = new Cryptr(process.env.ENCRYPTION_KEY); +const { reusable } = require('../../lib/threads'); module.exports = class TicketsSlashCommand extends SlashCommand { constructor(client, options) { @@ -116,33 +115,44 @@ module.exports = class TicketsSlashCommand extends SlashCommand { }, }); - if (open.length >= 1) { - fields.push({ - name: getMessage('commands.slash.tickets.response.fields.open.name'), - value: open.map(ticket => { - const topic = ticket.topic ? `- \`${decrypt(ticket.topic).replace(/\n/g, ' ').slice(0, 30)}\`` : ''; - return `> <#${ticket.id}> ${topic}`; - }).join('\n'), - }); - } + const worker = await reusable('crypto'); + try { + if (open.length >= 1) { + fields.push({ + name: getMessage('commands.slash.tickets.response.fields.open.name'), + value: (await Promise.all( + open.map(async ticket => { + const getTopic = async () => (await worker.decrypt(ticket.topic)).replace(/\n/g, ' ').substring(0, 30); + const topic = ticket.topic ? `- \`${await getTopic()}\`` : ''; + return `> <#${ticket.id}> ${topic}`; + }), + )).join('\n'), + }); + } - if (closed.length === 0) { - const newCommand = client.application.commands.cache.find(c => c.name === 'new'); - fields.push({ - name: getMessage('commands.slash.tickets.response.fields.closed.name'), - value: getMessage(`commands.slash.tickets.response.fields.closed.none.${ownOrOther}`, { - new: ``, - user: member.user.toString(), - }), - }); - } else { - fields.push({ - name: getMessage('commands.slash.tickets.response.fields.closed.name'), - value: closed.map(ticket => { - const topic = ticket.topic ? `- \`${decrypt(ticket.topic).replace(/\n/g, ' ').slice(0, 30)}\`` : ''; - return `> ${ticket.category.name} #${ticket.number} (\`${ticket.id}\`) ${topic}`; - }).join('\n'), - }); + if (closed.length === 0) { + const newCommand = client.application.commands.cache.find(c => c.name === 'new'); + fields.push({ + name: getMessage('commands.slash.tickets.response.fields.closed.name'), + value: getMessage(`commands.slash.tickets.response.fields.closed.none.${ownOrOther}`, { + new: ``, + user: member.user.toString(), + }), + }); + } else { + fields.push({ + name: getMessage('commands.slash.tickets.response.fields.closed.name'), + value: (await Promise.all( + closed.map(async ticket => { + const getTopic = async () => (await worker.decrypt(ticket.topic)).replace(/\n/g, ' ').substring(0, 30); + const topic = ticket.topic ? `- \`${await getTopic()}\`` : ''; + return `> ${ticket.category.name} #${ticket.number} (\`${ticket.id}\`) ${topic}`; + }), + )).join('\n'), + }); + } + } finally { + await worker.terminate(); } // TODO: add portal URL to view all (this list is limited to the last 10) diff --git a/src/commands/slash/topic.js b/src/commands/slash/topic.js index 9c5b331..6d2b15e 100644 --- a/src/commands/slash/topic.js +++ b/src/commands/slash/topic.js @@ -5,9 +5,8 @@ const { TextInputBuilder, TextInputStyle, } = require('discord.js'); -const Cryptr = require('cryptr'); -const { decrypt } = new Cryptr(process.env.ENCRYPTION_KEY); const ExtendedEmbedBuilder = require('../../lib/embed'); +const { quick } = require('../../lib/threads'); module.exports = class TopicSlashCommand extends SlashCommand { constructor(client, options) { @@ -66,7 +65,8 @@ module.exports = class TopicSlashCommand extends SlashCommand { .setPlaceholder(getMessage('modals.topic.placeholder')) .setRequired(true); - if (ticket.topic) field.setValue(decrypt(ticket.topic)); // why can't discord.js accept null or undefined :( + // why can't discord.js accept null or undefined :( + if (ticket.topic) field.setValue(await quick('crypto', w => w.decrypt(ticket.topic))); await interaction.showModal( new ModalBuilder() diff --git a/src/commands/slash/transcript.js b/src/commands/slash/transcript.js index 0158405..7a5d40a 100644 --- a/src/commands/slash/transcript.js +++ b/src/commands/slash/transcript.js @@ -7,9 +7,8 @@ const fs = require('fs'); const { join } = require('path'); const Mustache = require('mustache'); const { AttachmentBuilder } = require('discord.js'); -const Cryptr = require('cryptr'); -const { decrypt } = new Cryptr(process.env.ENCRYPTION_KEY); const ExtendedEmbedBuilder = require('../../lib/embed'); +const { quick } = require('../../lib/threads'); module.exports = class TranscriptSlashCommand extends SlashCommand { constructor(client, options) { @@ -61,31 +60,9 @@ module.exports = class TranscriptSlashCommand extends SlashCommand { /** @type {import("client")} */ const client = this.client; - ticket.claimedBy = ticket.archivedUsers.find(u => u.userId === ticket.claimedById); - ticket.closedBy = ticket.archivedUsers.find(u => u.userId === ticket.closedById); - ticket.createdBy = ticket.archivedUsers.find(u => u.userId === ticket.createdById); - - if (ticket.closedReason) ticket.closedReason = decrypt(ticket.closedReason); - if (ticket.feedback?.comment) ticket.feedback.comment = decrypt(ticket.feedback.comment); - if (ticket.topic) ticket.topic = decrypt(ticket.topic).replace(/\n/g, '\n\t'); - - ticket.archivedUsers.forEach((user, i) => { - if (user.displayName) user.displayName = decrypt(user.displayName); - user.username = decrypt(user.username); - ticket.archivedUsers[i] = user; - }); - - ticket.archivedMessages.forEach((message, i) => { - message.author = ticket.archivedUsers.find(u => u.userId === message.authorId); - message.content = JSON.parse(decrypt(message.content)); - message.text = message.content.content?.replace(/\n/g, '\n\t') ?? ''; - message.content.attachments?.forEach(a => (message.text += '\n\t' + a.url)); - message.content.embeds?.forEach(() => (message.text += '\n\t[embedded content]')); - message.number = 'M' + String(i + 1).padStart(ticket.archivedMessages.length.toString().length, '0'); - ticket.archivedMessages[i] = message; - }); - - ticket.pinnedMessageIds = ticket.pinnedMessageIds.map(id => ticket.archivedMessages.find(message => message.id === id)?.number); + // TODO: use a pool of multiple threads + // this is still slow for lots of messages + ticket = await quick('transcript', w => w(ticket)); const channelName = ticket.category.channelName .replace(/{+\s?(user)?name\s?}+/gi, ticket.createdBy?.username) diff --git a/src/commands/slash/transfer.js b/src/commands/slash/transfer.js index 857dbe9..ed22fc2 100644 --- a/src/commands/slash/transfer.js +++ b/src/commands/slash/transfer.js @@ -3,8 +3,8 @@ const { ApplicationCommandOptionType, EmbedBuilder, } = require('discord.js'); -const Cryptr = require('cryptr'); -const { decrypt } = new Cryptr(process.env.ENCRYPTION_KEY); +const { quick } = require('../../lib/threads'); + module.exports = class TransferSlashCommand extends SlashCommand { constructor(client, options) { @@ -71,7 +71,7 @@ module.exports = class TransferSlashCommand extends SlashCommand { }), interaction.channel.edit({ name: channelName, - topic: `${member.toString()}${ticket.topic?.length > 0 ? ` | ${decrypt(ticket.topic)}` : ''}`, + topic: `${member.toString()}${ticket.topic && ` | ${await quick('crypto', w => w.decrypt(ticket.topic))}`}`, }), interaction.channel.permissionOverwrites.edit( member, diff --git a/src/lib/stats.js b/src/lib/stats.js index c5e53c2..a01c325 100644 --- a/src/lib/stats.js +++ b/src/lib/stats.js @@ -39,7 +39,7 @@ async function sendToHouston(client) { activated_users: users._count, arch: process.arch, database: process.env.DB_PROVIDER, - guilds: await relativePool(0.25, 'stats', pool => Promise.all( + guilds: await relativePool(.25, 'stats', pool => Promise.all( guilds .filter(guild => client.guilds.cache.has(guild.id)) .map(guild => { diff --git a/src/lib/threads.js b/src/lib/threads.js index 9a17dd5..6e4b472 100644 --- a/src/lib/threads.js +++ b/src/lib/threads.js @@ -8,13 +8,13 @@ const { cpus } = require('node:os'); /** * Use a thread pool of a fixed size - * @param {number} size number of threads * @param {string} name name of file in workers directory * @param {function} fun async function + * @param {import('threads/dist/master/pool').PoolOptions} options * @returns {Promise} */ -async function pool(size, name, fun) { - const pool = Pool(() => spawn(new Worker(`./workers/${name}.js`)), { size }); +async function pool(name, fun, options) { + const pool = Pool(() => spawn(new Worker(`./workers/${name}.js`)), options); try { return await fun(pool); } finally { @@ -40,19 +40,35 @@ async function quick(name, fun) { /** * Use a thread pool of a variable size - * @param {number} size fraction of available CPU cores to use (ceil'd) + * @param {number} fraction fraction of available CPU cores to use (ceil'd) * @param {string} name name of file in workers directory * @param {function} fun async function + * @param {import('threads/dist/master/pool').PoolOptions} options * @returns {Promise} */ -function relativePool(fraction, ...args) { +function relativePool(fraction, name, fun, options) { // ! ceiL: at least 1 - const poolSize = Math.ceil(fraction * cpus().length); - return pool(poolSize, ...args); + const size = Math.ceil(fraction * cpus().length); + return pool(name, fun, { + ...options, + size, + }); } +/** + * Spawn one thread + * @param {string} name name of file in workers directory + * @returns {Promise<{terminate: function}>} + */ +async function reusable(name) { + const thread = await spawn(new Worker(`./workers/${name}.js`)); + thread.terminate = () => Thread.terminate(thread); + return thread; +}; + module.exports = { pool, quick, relativePool, + reusable, }; diff --git a/src/lib/tickets/archiver.js b/src/lib/tickets/archiver.js index 6687018..f1a8bd8 100644 --- a/src/lib/tickets/archiver.js +++ b/src/lib/tickets/archiver.js @@ -1,5 +1,5 @@ -const Cryptr = require('cryptr'); -const { encrypt } = new Cryptr(process.env.ENCRYPTION_KEY); +const { reusable } = require('../threads'); + /** * Returns highest (roles.highest) hoisted role, or everyone @@ -71,85 +71,90 @@ module.exports = class TicketArchiver { }); } - for (const member of members) { - const data = { - avatar: member.avatar || member.user.avatar, // TODO: save avatar in user/avatars/ - bot: member.user.bot, - discriminator: member.user.discriminator, - displayName: member.displayName ? encrypt(member.displayName) : null, - roleId: !!member && hoistedRole(member).id, - ticketId, - userId: member.user.id, - username: encrypt(member.user.username), - }; - await this.client.prisma.archivedUser.upsert({ - create: data, - update: data, - where: { - ticketId_userId: { - ticketId, - userId: member.user.id, + const worker = await reusable('crypto'); + try { + for (const member of members) { + const data = { + avatar: member.avatar || member.user.avatar, // TODO: save avatar in user/avatars/ + bot: member.user.bot, + discriminator: member.user.discriminator, + displayName: member.displayName ? await worker.encrypt(member.displayName) : null, + roleId: !!member && hoistedRole(member).id, + ticketId, + userId: member.user.id, + username: await worker.encrypt(member.user.username), + }; + await this.client.prisma.archivedUser.upsert({ + create: data, + update: data, + where: { + ticketId_userId: { + ticketId, + userId: member.user.id, + }, + }, + }); + } + + let reference; + if (message.reference) reference = await message.fetchReference(); + + const messageD = { + author: { + connect: { + ticketId_userId: { + ticketId, + userId: message.author?.id || 'default', + }, }, }, - }); - } - - let reference; - if (message.reference) reference = await message.fetchReference(); - - const messageD = { - author: { - connect: { - ticketId_userId: { - ticketId, - userId: message.author?.id || 'default', - }, - }, - }, - content: encrypt( - JSON.stringify({ - attachments: [...message.attachments.values()], - components: [...message.components.values()], - content: message.content, - embeds: message.embeds.map(embed => ({ ...embed })), - reference: reference ? reference.id : null, - }), - ), - createdAt: message.createdAt, - edited: !!message.editedAt, - external, - id: message.id, - }; - - return await this.client.prisma.ticket.update({ - data: { - archivedChannels: { - upsert: channels.map(channel => { - const data = { - channelId: channel.id, - name: channel.name, - }; - return { - create: data, - update: data, - where: { - ticketId_channelId: { - channelId: channel.id, - ticketId, - }, - }, - }; + content: await worker.encrypt( + JSON.stringify({ + attachments: [...message.attachments.values()], + components: [...message.components.values()], + content: message.content, + embeds: message.embeds.map(embed => ({ ...embed })), + reference: reference ? reference.id : null, }), - }, - archivedMessages: { - upsert: { - create: messageD, - update: messageD, - where: { id: message.id }, + ), + createdAt: message.createdAt, + edited: !!message.editedAt, + external, + id: message.id, + }; + + return await this.client.prisma.ticket.update({ + data: { + archivedChannels: { + upsert: channels.map(channel => { + const data = { + channelId: channel.id, + name: channel.name, + }; + return { + create: data, + update: data, + where: { + ticketId_channelId: { + channelId: channel.id, + ticketId, + }, + }, + }; + }), + }, + archivedMessages: { + upsert: { + create: messageD, + update: messageD, + where: { id: message.id }, + }, }, }, - }, - where: { id: ticketId }, - }); + where: { id: ticketId }, + }); + } finally { + await worker.terminate(); + } } -}; \ No newline at end of file +}; diff --git a/src/lib/tickets/manager.js b/src/lib/tickets/manager.js index d8ed05d..e17e4bf 100644 --- a/src/lib/tickets/manager.js +++ b/src/lib/tickets/manager.js @@ -19,13 +19,13 @@ const { logTicketEvent } = require('../logging'); const { isStaff } = require('../users'); const { Collection } = require('discord.js'); const spacetime = require('spacetime'); -const Cryptr = require('cryptr'); -const { - decrypt, - encrypt, -} = new Cryptr(process.env.ENCRYPTION_KEY); + const { getSUID } = require('../logging'); const { getAverageTimes } = require('../stats'); +const { + quick, + reusable, +} = require('../threads'); /** * @typedef {import('@prisma/client').Category & @@ -148,7 +148,9 @@ module.exports = class TicketManager { /** * @param {object} data * @param {string} data.categoryId - * @param {import("discord.js").ChatInputCommandInteraction|import("discord.js").ButtonInteraction|import("discord.js").SelectMenuInteraction} data.interaction + * @param {import("discord.js").ChatInputCommandInteraction + * | import("discord.js").ButtonInteraction + * | import("discord.js").SelectMenuInteraction} data.interaction * @param {string?} [data.topic] */ async create({ @@ -353,7 +355,9 @@ module.exports = class TicketManager { /** * @param {object} data * @param {string} data.category - * @param {import("discord.js").ButtonInteraction|import("discord.js").SelectMenuInteraction|import("discord.js").ModalSubmitInteraction} data.interaction + * @param {import("discord.js").ButtonInteraction + * | import("discord.js").SelectMenuInteraction + * | import("discord.js").ModalSubmitInteraction} data.interaction * @param {string?} [data.topic] */ async postQuestions({ @@ -367,11 +371,22 @@ module.exports = class TicketManager { let answers; if (interaction.isModalSubmit()) { if (action === 'questions') { - answers = category.questions.filter(q => q.type === 'TEXT').map(q => ({ - questionId: q.id, - userId: interaction.user.id, - value: interaction.fields.getTextInputValue(q.id) ? encrypt(interaction.fields.getTextInputValue(q.id)) : '', - })); + const worker = await reusable('crypto'); + try { + answers = await Promise.all( + category.questions + .filter(q => q.type === 'TEXT') + .map(async q => ({ + questionId: q.id, + userId: interaction.user.id, + value: interaction.fields.getTextInputValue(q.id) + ? await worker.encrypt(interaction.fields.getTextInputValue(q.id)) + : '', // TODO: maybe this should be null? + })), + ); + } finally { + await worker.terminate(); + } if (category.customTopic) topic = interaction.fields.getTextInputValue(category.customTopic); } else if (action === 'topic') { topic = interaction.fields.getTextInputValue('topic'); @@ -612,7 +627,7 @@ module.exports = class TicketManager { embed.addFields({ inline: false, name: getMessage('ticket.references_ticket.fields.topic'), - value: decrypt(ticket.topic), + value: await quick('crypto', worker => worker.decrypt(ticket.topic)), }); } await channel.send({ embeds: [embed] }); @@ -631,7 +646,7 @@ module.exports = class TicketManager { id: channel.id, number, openingMessageId: sent.id, - topic: topic ? encrypt(topic) : null, + topic: topic ? await quick('crypto', worker => worker.encrypt(topic)) : null, }; if (referencesTicketId) data.referencesTicket = { connect: { id: referencesTicketId } }; if (answers) data.questionAnswers = { createMany: { data: answers } }; @@ -1073,7 +1088,9 @@ module.exports = class TicketManager { } /** - * @param {import("discord.js").ChatInputCommandInteraction|import("discord.js").ButtonInteraction|import("discord.js").ModalSubmitInteraction} interaction + * @param {import("discord.js").ChatInputCommandInteraction + * | import("discord.js").ButtonInteraction + * | import("discord.js").ModalSubmitInteraction} interaction * @param {string} reason */ async requestClose(interaction, reason) { @@ -1143,7 +1160,9 @@ module.exports = class TicketManager { } /** - * @param {import("discord.js").ChatInputCommandInteraction|import("discord.js").ButtonInteraction|import("discord.js").ModalSubmitInteraction} interaction + * @param {import("discord.js").ChatInputCommandInteraction + * | import("discord.js").ButtonInteraction + * | import("discord.js").ModalSubmitInteraction} interaction */ async acceptClose(interaction) { const ticket = await this.getTicket(interaction.channel.id); @@ -1191,7 +1210,7 @@ module.exports = class TicketManager { where: { id: closedBy }, }, } || undefined, // Prisma wants undefined not null because it is a relation - closedReason: reason && encrypt(reason), + closedReason: reason && await quick('crypto', worker => worker.encrypt(reason)), messageCount: archivedMessages, open: false, }; @@ -1248,7 +1267,7 @@ module.exports = class TicketManager { embed.addFields({ inline: true, name: getMessage('dm.closed.fields.topic'), - value: decrypt(ticket.topic), + value: await quick('crypto', worker => worker.decrypt(ticket.topic)), }); } diff --git a/src/lib/workers/crypto.js b/src/lib/workers/crypto.js new file mode 100644 index 0000000..d951278 --- /dev/null +++ b/src/lib/workers/crypto.js @@ -0,0 +1,11 @@ +const { expose } = require('threads/worker'); +const Cryptr = require('cryptr'); +const { + encrypt, + decrypt, +} = new Cryptr(process.env.ENCRYPTION_KEY); + +expose({ + decrypt, + encrypt, +}); diff --git a/src/lib/workers/transcript.js b/src/lib/workers/transcript.js new file mode 100644 index 0000000..8f40aad --- /dev/null +++ b/src/lib/workers/transcript.js @@ -0,0 +1,36 @@ +const { expose } = require('threads/worker'); +const Cryptr = require('cryptr'); +const { decrypt } = new Cryptr(process.env.ENCRYPTION_KEY); + +function getTranscript(ticket) { + ticket.claimedBy = ticket.archivedUsers.find(u => u.userId === ticket.claimedById); + ticket.closedBy = ticket.archivedUsers.find(u => u.userId === ticket.closedById); + ticket.createdBy = ticket.archivedUsers.find(u => u.userId === ticket.createdById); + + if (ticket.closedReason) ticket.closedReason = decrypt(ticket.closedReason); + if (ticket.feedback?.comment) ticket.feedback.comment = decrypt(ticket.feedback.comment); + if (ticket.topic) ticket.topic = decrypt(ticket.topic).replace(/\n/g, '\n\t'); + + ticket.archivedUsers.forEach((user, i) => { + if (user.displayName) user.displayName = decrypt(user.displayName); + user.username = decrypt(user.username); + ticket.archivedUsers[i] = user; + }); + + ticket.archivedMessages.forEach((message, i) => { + message.author = ticket.archivedUsers.find(u => u.userId === message.authorId); + message.content = JSON.parse(decrypt(message.content)); + message.text = message.content.content?.replace(/\n/g, '\n\t') ?? ''; + message.content.attachments?.forEach(a => (message.text += '\n\t' + a.url)); + message.content.embeds?.forEach(() => (message.text += '\n\t[embedded content]')); + message.number = 'M' + String(i + 1).padStart(ticket.archivedMessages.length.toString().length, '0'); + ticket.archivedMessages[i] = message; + }); + + ticket.pinnedMessageIds = ticket.pinnedMessageIds.map(id => ticket.archivedMessages.find(message => message.id === id)?.number); + return ticket; +} + +expose(getTranscript); + + diff --git a/src/listeners/client/messageDelete.js b/src/listeners/client/messageDelete.js index d6ba785..8675f54 100644 --- a/src/listeners/client/messageDelete.js +++ b/src/listeners/client/messageDelete.js @@ -1,8 +1,7 @@ const { Listener } = require('@eartharoid/dbf'); const { AuditLogEvent } = require('discord.js'); const { logMessageEvent } = require('../../lib/logging'); -const Cryptr = require('cryptr'); -const { decrypt } = new Cryptr(process.env.ENCRYPTION_KEY); +const { quick } = require('../../lib/threads'); module.exports = class extends Listener { constructor(client, options) { @@ -38,8 +37,11 @@ module.exports = class extends Listener { if (ticket.guild.archive) { try { const archived = await client.prisma.archivedMessage.findUnique({ where: { id: message.id } }); - if (archived) { - if (!content) content = JSON.parse(decrypt(archived.content)).content; // won't be cleaned + if (archived?.content) { + if (!content) { + const string = await quick('crypto', worker => worker.decrypt(archived.content)); + content = JSON.parse(string).content; // won't be cleaned + } await client.prisma.archivedMessage.update({ data: { deleted: true }, where: { id: message.id }, diff --git a/src/modals/feedback.js b/src/modals/feedback.js index 2c49243..2ae9611 100644 --- a/src/modals/feedback.js +++ b/src/modals/feedback.js @@ -1,7 +1,6 @@ const { Modal } = require('@eartharoid/dbf'); const ExtendedEmbedBuilder = require('../lib/embed'); -const Cryptr = require('cryptr'); -const { encrypt } = new Cryptr(process.env.ENCRYPTION_KEY); +const { quick } = require('../lib/threads'); module.exports = class FeedbackModal extends Modal { constructor(client, options) { @@ -26,7 +25,7 @@ module.exports = class FeedbackModal extends Modal { rating = Math.min(Math.max(rating, 1), 5); // clamp between 1 and 5 (0 and null become 1, 6 becomes 5) const data = { - comment: comment?.length > 0 ? encrypt(comment) : null, + comment: comment?.length > 0 ? await quick('crypto', worker => worker.encrypt(comment)) : null, guild: { connect: { id: interaction.guild.id } }, rating, user: { connect: { id: interaction.user.id } }, @@ -65,4 +64,4 @@ module.exports = class FeedbackModal extends Modal { }); } } -}; \ No newline at end of file +}; diff --git a/src/modals/questions.js b/src/modals/questions.js index 799e192..05fc1be 100644 --- a/src/modals/questions.js +++ b/src/modals/questions.js @@ -2,11 +2,8 @@ const { Modal } = require('@eartharoid/dbf'); const { EmbedBuilder } = require('discord.js'); const ExtendedEmbedBuilder = require('../lib/embed'); const { logTicketEvent } = require('../lib/logging'); -const Cryptr = require('cryptr'); -const { - encrypt, - decrypt, -} = new Cryptr(process.env.ENCRYPTION_KEY); +const { reusable } = require('../lib/threads'); + module.exports = class QuestionsModal extends Modal { constructor(client, options) { @@ -26,101 +23,111 @@ module.exports = class QuestionsModal extends Modal { const client = this.client; if (id.edit) { - await interaction.deferReply({ ephemeral: true }); + const worker = await reusable('crypto'); + try { + await interaction.deferReply({ ephemeral: true }); - const { category } = await client.prisma.ticket.findUnique({ - select: { category: { select: { customTopic: true } } }, - where: { id: interaction.channel.id }, - }); - const select = { - createdById: true, - guild: { - select: { - footer: true, - locale: true, - successColour: true, - }, - }, - id: true, - openingMessageId: true, - questionAnswers: { include: { question: true } }, - }; - const original = await client.prisma.ticket.findUnique({ - select, - where: { id: interaction.channel.id }, - }); - - let topic; - if (category.customTopic) { - const customTopicAnswer = original.questionAnswers.find(a => a.question.id === category.customTopic); - if (!customTopicAnswer) throw new Error('Custom topic answer not found'); - topic = interaction.fields.getTextInputValue(String(customTopicAnswer.id)); - } - - const ticket = await client.prisma.ticket.update({ - data: { - questionAnswers: { - update: interaction.fields.fields.map(f => ({ - data: { value: f.value ? encrypt(f.value) : '' }, - where: { id: Number(f.customId) }, - })), - }, - topic: topic ? encrypt(topic) : null, - }, - select, - where: { id: interaction.channel.id }, - }); - const getMessage = client.i18n.getLocale(ticket.guild.locale); - - if (topic) await interaction.channel.setTopic(`<@${ticket.createdById}> | ${topic}`); - - const opening = await interaction.channel.messages.fetch(ticket.openingMessageId); - if (opening && opening.embeds.length >= 2) { - const embeds = [...opening.embeds]; - embeds[1] = new EmbedBuilder(embeds[1].data) - .setFields( - ticket.questionAnswers - .map(a => ({ - name: a.question.label, - value: a.value ? decrypt(a.value) : getMessage('ticket.answers.no_value'), - })), - ); - await opening.edit({ embeds }); - } - - await interaction.editReply({ - embeds: [ - new ExtendedEmbedBuilder({ - iconURL: interaction.guild.iconURL(), - text: ticket.guild.footer, - }) - .setColor(ticket.guild.successColour) - .setTitle(getMessage('ticket.edited.title')) - .setDescription(getMessage('ticket.edited.description')), - ], - }); - - /** @param {ticket} ticket */ - const makeDiff = ticket => { - const diff = {}; - ticket.questionAnswers.forEach(a => { - diff[a.question.label] = a.value ? decrypt(a.value) : getMessage('ticket.answers.no_value'); + const { category } = await client.prisma.ticket.findUnique({ + select: { category: { select: { customTopic: true } } }, + where: { id: interaction.channel.id }, + }); + const select = { + createdById: true, + guild: { + select: { + footer: true, + locale: true, + successColour: true, + }, + }, + id: true, + openingMessageId: true, + questionAnswers: { include: { question: true } }, + }; + const original = await client.prisma.ticket.findUnique({ + select, + where: { id: interaction.channel.id }, }); - return diff; - }; - logTicketEvent(this.client, { - action: 'update', - diff: { - original: makeDiff(original), - updated: makeDiff(ticket), - }, - target: { - id: ticket.id, - name: `<#${ticket.id}>`, - }, - userId: interaction.user.id, - }); + let topic; + if (category.customTopic) { + const customTopicAnswer = original.questionAnswers.find(a => a.question.id === category.customTopic); + if (!customTopicAnswer) throw new Error('Custom topic answer not found'); + topic = interaction.fields.getTextInputValue(String(customTopicAnswer.id)); + } + + const ticket = await client.prisma.ticket.update({ + data: { + questionAnswers: { + update: await Promise.all( + interaction.fields.fields + .map(async f => ({ + data: { value: f.value ? await worker.encrypt(f.value) : '' }, + where: { id: Number(f.customId) }, + })), + ), + }, + topic: topic ? await worker.encrypt(topic) : null, + }, + select, + where: { id: interaction.channel.id }, + }); + const getMessage = client.i18n.getLocale(ticket.guild.locale); + + if (topic) await interaction.channel.setTopic(`<@${ticket.createdById}> | ${topic}`); + + const opening = await interaction.channel.messages.fetch(ticket.openingMessageId); + if (opening && opening.embeds.length >= 2) { + const embeds = [...opening.embeds]; + embeds[1] = new EmbedBuilder(embeds[1].data) + .setFields( + await Promise.all( + ticket.questionAnswers + .map(async a => ({ + name: a.question.label, + value: a.value ? await worker.decrypt(a.value) : getMessage('ticket.answers.no_value'), + })), + ), + ); + await opening.edit({ embeds }); + } + + await interaction.editReply({ + embeds: [ + new ExtendedEmbedBuilder({ + iconURL: interaction.guild.iconURL(), + text: ticket.guild.footer, + }) + .setColor(ticket.guild.successColour) + .setTitle(getMessage('ticket.edited.title')) + .setDescription(getMessage('ticket.edited.description')), + ], + }); + + /** @param {ticket} ticket */ + const makeDiff = async ticket => { + const diff = {}; + for (const a of ticket.questionAnswers) { + diff[a.question.label] = a.value ? await worker.decrypt(a.value) : getMessage('ticket.answers.no_value'); + } + return diff; + }; + + logTicketEvent(this.client, { + action: 'update', + diff: { + original: await makeDiff(original), + updated: await makeDiff(ticket), + }, + target: { + id: ticket.id, + name: `<#${ticket.id}>`, + }, + userId: interaction.user.id, + }); + } finally { + await worker.terminate(); + } } else { await this.client.tickets.postQuestions({ ...id, diff --git a/src/modals/topic.js b/src/modals/topic.js index 1b9ab94..0f61539 100644 --- a/src/modals/topic.js +++ b/src/modals/topic.js @@ -2,11 +2,7 @@ const { Modal } = require('@eartharoid/dbf'); const { EmbedBuilder } = require('discord.js'); const ExtendedEmbedBuilder = require('../lib/embed'); const { logTicketEvent } = require('../lib/logging'); -const Cryptr = require('cryptr'); -const { - encrypt, - decrypt, -} = new Cryptr(process.env.ENCRYPTION_KEY); +const { reusable } = require('../lib/threads'); module.exports = class TopicModal extends Modal { constructor(client, options) { @@ -21,76 +17,82 @@ module.exports = class TopicModal extends Modal { const client = this.client; if (id.edit) { - await interaction.deferReply({ ephemeral: true }); - const topic = interaction.fields.getTextInputValue('topic'); - const select = { - createdById: true, - guild: { - select: { - footer: true, - locale: true, - successColour: true, + const worker = await reusable('crypto'); + try { + await interaction.deferReply({ ephemeral: true }); + const topic = interaction.fields.getTextInputValue('topic'); + const select = { + createdById: true, + guild: { + select: { + footer: true, + locale: true, + successColour: true, + }, }, - }, - id: true, - openingMessageId: true, - topic: true, - }; - const original = await client.prisma.ticket.findUnique({ - select, - where: { id: interaction.channel.id }, - }); - const ticket = await client.prisma.ticket.update({ - data: { topic: topic ? encrypt(topic) : null }, - select, - where: { id: interaction.channel.id }, - }); - const getMessage = client.i18n.getLocale(ticket.guild.locale); + id: true, + openingMessageId: true, + topic: true, + }; + const original = await client.prisma.ticket.findUnique({ + select, + where: { id: interaction.channel.id }, + }); + const ticket = await client.prisma.ticket.update({ + data: { topic: topic ? await worker.encrypt(topic) : null }, + select, + where: { id: interaction.channel.id }, + }); + const getMessage = client.i18n.getLocale(ticket.guild.locale); - if (topic) interaction.channel.setTopic(`<@${ticket.createdById}> | ${topic}`); + if (topic) interaction.channel.setTopic(`<@${ticket.createdById}> | ${topic}`); - const opening = await interaction.channel.messages.fetch(ticket.openingMessageId); - if (opening && opening.embeds.length >= 2) { - const embeds = [...opening.embeds]; - embeds[1] = new EmbedBuilder(embeds[1].data) - .setFields({ - name: getMessage('ticket.opening_message.fields.topic'), - value: topic, - }); - await opening.edit({ embeds }); + const opening = await interaction.channel.messages.fetch(ticket.openingMessageId); + if (opening && opening.embeds.length >= 2) { + const embeds = [...opening.embeds]; + embeds[1] = new EmbedBuilder(embeds[1].data) + .setFields({ + name: getMessage('ticket.opening_message.fields.topic'), + value: topic, + }); + await opening.edit({ embeds }); + } + + await interaction.editReply({ + embeds: [ + new ExtendedEmbedBuilder({ + iconURL: interaction.guild.iconURL(), + text: ticket.guild.footer, + }) + .setColor(ticket.guild.successColour) + .setTitle(getMessage('ticket.edited.title')) + .setDescription(getMessage('ticket.edited.description')), + ], + }); + + /** @param {ticket} ticket */ + const makeDiff = async ticket => { + const diff = {}; + diff[getMessage('ticket.opening_message.fields.topic')] = ticket.topic ? await worker.decrypt(ticket.topic) : ' '; + return diff; + }; + + logTicketEvent(this.client, { + action: 'update', + diff: { + original: await makeDiff(original), + updated: await makeDiff(ticket), + }, + target: { + id: ticket.id, + name: `<#${ticket.id}>`, + }, + userId: interaction.user.id, + }); + + } finally { + await worker.terminate(); } - - await interaction.editReply({ - embeds: [ - new ExtendedEmbedBuilder({ - iconURL: interaction.guild.iconURL(), - text: ticket.guild.footer, - }) - .setColor(ticket.guild.successColour) - .setTitle(getMessage('ticket.edited.title')) - .setDescription(getMessage('ticket.edited.description')), - ], - }); - - /** @param {ticket} ticket */ - const makeDiff = ticket => { - const diff = {}; - diff[getMessage('ticket.opening_message.fields.topic')] = ticket.topic ? decrypt(ticket.topic) : ' '; - return diff; - }; - - logTicketEvent(this.client, { - action: 'update', - diff: { - original: makeDiff(original), - updated: makeDiff(ticket), - }, - target: { - id: ticket.id, - name: `<#${ticket.id}>`, - }, - userId: interaction.user.id, - }); } else { await this.client.tickets.postQuestions({ ...id, @@ -98,4 +100,4 @@ module.exports = class TopicModal extends Modal { }); } } -}; \ No newline at end of file +};