diff --git a/scripts/preinstall.js b/scripts/preinstall.js index 2f00282..a6cba15 100644 --- a/scripts/preinstall.js +++ b/scripts/preinstall.js @@ -10,6 +10,7 @@ const env = { HTTP_BIND: 8080, HTTP_EXTERNAL: 'http://localhost:8080', PORTAL: '', + PUBLIC: false, SUPER: '319467558166069248', }; diff --git a/src/buttons/edit.js b/src/buttons/edit.js index 479d222..dc82a10 100644 --- a/src/buttons/edit.js +++ b/src/buttons/edit.js @@ -1,4 +1,13 @@ const { Button } = require('@eartharoid/dbf'); +const { + ActionRowBuilder, + ModalBuilder, + SelectMenuBuilder, + SelectMenuOptionBuilder, + TextInputBuilder, + TextInputStyle, +} = require('discord.js'); +const emoji = require('node-emoji'); module.exports = class EditButton extends Button { constructor(client, options) { @@ -8,5 +17,93 @@ module.exports = class EditButton extends Button { }); } - async run(id, interaction) { } + async run(id, interaction) { + /** @type {import("client")} */ + const client = this.client; + + const ticket = await client.prisma.ticket.findUnique({ + select: { + category: { select: { name: true } }, + guild: { select: { locale: true } }, + questionAnswers: { include: { question: true } }, + topic: true, + }, + where: { id: interaction.channel.id }, + }); + + const getMessage = client.i18n.getLocale(ticket.guild.locale); + + if (ticket.questionAnswers.length === 0) { + await interaction.showModal( + new ModalBuilder() + .setCustomId(JSON.stringify({ + action: 'topic', + edit: true, + })) + .setTitle(ticket.category.name) + .setComponents( + new ActionRowBuilder() + .setComponents( + new TextInputBuilder() + .setCustomId('topic') + .setLabel(getMessage('modals.topic.label')) + .setStyle(TextInputStyle.Paragraph) + .setMaxLength(1000) + .setMinLength(5) + .setPlaceholder(getMessage('modals.topic.placeholder')) + .setRequired(true) + .setValue(ticket.topic || ''), + ), + ), + ); + } 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') { + return new ActionRowBuilder() + .setComponents( + 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) + .setValue(a.value || a.question.value), + ); + } else if (a.question.type === 'MENU') { + return new ActionRowBuilder() + .setComponents( + new SelectMenuBuilder() + .setCustomId(a.question.id) + .setPlaceholder(a.question.placeholder || a.question.label) + .setMaxValues(a.question.maxLength) + .setMinValues(a.question.minLength) + .setOptions( + a.question.options.map((o, i) => { + const builder = new SelectMenuOptionBuilder() + .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; + }), + ), + ); + } + }), + ), + ); + } + } }; \ No newline at end of file diff --git a/src/i18n/en-GB.yml b/src/i18n/en-GB.yml index c9c169c..8a66dbd 100644 --- a/src/i18n/en-GB.yml +++ b/src/i18n/en-GB.yml @@ -169,6 +169,7 @@ log: claim: claimed close: closed unclaim: released + update: updated menus: category: placeholder: Select a ticket category @@ -224,6 +225,9 @@ ticket: title: ✅ Ticket created answers: no_value: '*No response*' + edited: + description: Your changes have been saved. + title: ✅ Ticket updated opening_message: content: | {staff} diff --git a/src/lib/logging.js b/src/lib/logging.js index 1cf2e23..2be4d98 100644 --- a/src/lib/logging.js +++ b/src/lib/logging.js @@ -129,7 +129,7 @@ async function logAdminEvent(client, { * @param {string} details.action */ async function logTicketEvent(client, { - userId, action, target, + userId, action, target, diff, }) { const ticket = await client.prisma.ticket.findUnique({ include: { guild: true }, @@ -143,9 +143,10 @@ async function logTicketEvent(client, { if (!ticket.guild.logChannel) return; const colour = action === 'create' ? 'Aqua' : action === 'close' - ? 'DarkAqua' : action === 'claim' - ? 'LuminousVividPink' : action === 'unclaim' - ? 'DarkVividPink' : 'Default'; + ? 'DarkAqua' : action === 'update' + ? 'Purple' : action === 'claim' + ? 'LuminousVividPink' : action === 'unclaim' + ? 'DarkVividPink' : 'Default'; const getMessage = client.i18n.getLocale(ticket.guild.locale); const i18nOptions = { user: `<@${member.user.id}>`, @@ -160,14 +161,8 @@ async function logTicketEvent(client, { iconURL: member.displayAvatarURL(), name: member.displayName, }) - .setTitle(getMessage('log.ticket.title', { - ...i18nOptions, - verb: getMessage(`log.ticket.verb.${action}`), - })) - .setDescription(getMessage('log.ticket.description', { - ...i18nOptions, - verb: getMessage(`log.ticket.verb.${action}`), - })) + .setTitle(getMessage('log.ticket.title', i18nOptions)) + .setDescription(getMessage('log.ticket.description', i18nOptions)) .addFields([ { name: getMessage('log.ticket.ticket'), @@ -176,6 +171,15 @@ async function logTicketEvent(client, { ]), ]; + if (diff && diff.original) { + embeds.push( + new EmbedBuilder() + .setColor(colour) + .setTitle(getMessage('log.admin.changes')) + .setFields(makeDiff(diff)), + ); + } + return await channel.send({ embeds }); } diff --git a/src/modals/questions.js b/src/modals/questions.js index f7c6b8e..3f35db1 100644 --- a/src/modals/questions.js +++ b/src/modals/questions.js @@ -1,4 +1,7 @@ const { Modal } = require('@eartharoid/dbf'); +const { EmbedBuilder } = require('discord.js'); +const ExtendedEmbedBuilder = require('../lib/embed'); +const { logTicketEvent } = require('../lib/logging'); module.exports = class QuestionsModal extends Modal { constructor(client, options) { @@ -14,9 +17,103 @@ module.exports = class QuestionsModal extends Modal { * @param {import("discord.js").ModalSubmitInteraction} interaction */ async run(id, interaction) { - await this.client.tickets.postQuestions({ - ...id, - interaction, - }); + /** @type {import("client")} */ + const client = this.client; + + if (id.edit) { + await interaction.deferReply({ ephemeral: true }); + const { category } = await client.prisma.ticket.findUnique({ + select: { category: { select: { customTopic: true } } }, + where: { id: interaction.channel.id }, + }); + let topic; + if (category.customTopic) topic = interaction.fields.getTextInputValue(category.customTopic); + 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 }, + }); + const ticket = await client.prisma.ticket.update({ + data: { + questionAnswers: { + update: interaction.fields.fields.map(f => ({ + data: { value: f.value }, + where: { id: Number(f.customId) }, + })), + }, + topic, + }, + 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 || 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 || getMessage('ticket.answers.no_value'); + }); + 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, + interaction, + }); + } } }; \ No newline at end of file diff --git a/src/modals/topic.js b/src/modals/topic.js index 84544d3..e9af947 100644 --- a/src/modals/topic.js +++ b/src/modals/topic.js @@ -1,4 +1,7 @@ const { Modal } = require('@eartharoid/dbf'); +const { EmbedBuilder } = require('discord.js'); +const ExtendedEmbedBuilder = require('../lib/embed'); +const { logTicketEvent } = require('../lib/logging'); module.exports = class TopicModal extends Modal { constructor(client, options) { @@ -9,9 +12,84 @@ module.exports = class TopicModal extends Modal { } async run(id, interaction) { - await this.client.tickets.postQuestions({ - ...id, - interaction, - }); + /** @type {import("client")} */ + 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, + }, + }, + id: true, + openingMessageId: true, + }; + const original = await client.prisma.ticket.findUnique({ + select, + where: { id: interaction.channel.id }, + }); + const ticket = await client.prisma.ticket.update({ + data: { topic }, + 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({ + 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 = ticket => { + const diff = {}; + diff[getMessage('ticket.opening_message.fields.topic')] = 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, + interaction, + }); + } } }; \ No newline at end of file diff --git a/src/routes/api/client.js b/src/routes/api/client.js index f2a7649..1e8262c 100644 --- a/src/routes/api/client.js +++ b/src/routes/api/client.js @@ -21,9 +21,10 @@ module.exports.get = () => ({ discriminator: client.user.discriminator, id: client.user.id, portal: process.env.PORTAL || null, + public: !!process.env.PUBLIC, stats: { activatedUsers: users.length, - archivedMessages: users.reduce((total, user) => total + user.messageCount, 0), // don't count archivedMessage table rows, they get deleted + archivedMessages: users.reduce((total, user) => total + user.messageCount, 0), // don't count archivedMessage table rows, they can be deleted avgResolutionTime: ms(closedTickets.reduce((total, ticket) => total + (ticket.closedAt - ticket.createdAt), 0) ?? 1 / closedTickets.length), avgResponseTime: ms(closedTickets.reduce((total, ticket) => total + (ticket.firstResponseAt - ticket.createdAt), 0) ?? 1 / closedTickets.length), categories: await client.prisma.category.count(),