/* eslint-disable no-underscore-dangle */ /* eslint-disable max-lines */ const TicketArchiver = require('./archiver'); const { ActionRowBuilder, ButtonBuilder, ButtonStyle, inlineCode, ModalBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, TextInputBuilder, TextInputStyle, } = require('discord.js'); const emoji = require('node-emoji'); const ms = require('ms'); const ExtendedEmbedBuilder = require('../embed'); const { logTicketEvent } = require('../logging'); const { isStaff } = require('../users'); const { Collection } = require('discord.js'); const spacetime = require('spacetime'); const { getSUID } = require('../logging'); const { getAverageTimes } = require('../stats'); const { quick, reusable, } = require('../threads'); /** * @typedef {import('@prisma/client').Category & * {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")} */ this.client = client; this.archiver = new TicketArchiver(client); this.$count = { categories: {} }; this.$numbers = {}; this.$stale = new Collection(); } /** * Retrieve cached category data * @param {string} categoryId the category ID * @param {boolean} force bypass & update the cache? * @returns {Promise} */ async getCategory(categoryId, force) { const cacheKey = `cache/category+guild+questions:${categoryId}`; /** @type {CategoryGuildQuestions} */ let category = await this.client.keyv.get(cacheKey); if (!category || force) { category = await this.client.prisma.category.findUnique({ include: { guild: true, questions: { orderBy: { order: 'asc' } }, }, where: { id: categoryId }, }); await this.client.keyv.set(cacheKey, category, ms('12h')); } return category; } /** * 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) { this.$count.categories[categoryId] ||= {}; let count = this.$count.categories[categoryId].total; if (!count) { count = await this.client.prisma.ticket.count({ where: { categoryId, open: true, }, }); this.$count.categories[categoryId].total = count; } return count; } async getMemberCount(categoryId, memberId) { this.$count.categories[categoryId] ||= {}; let count = this.$count.categories[categoryId][memberId]; if (!count) { count = await this.client.prisma.ticket.count({ where: { categoryId: categoryId, createdById: memberId, open: true, }, }); this.$count.categories[categoryId][memberId] = count; } return count; } async getCooldown(categoryId, memberId) { const cacheKey = `cooldowns/category-member:${categoryId}-${memberId}`; return await this.client.keyv.get(cacheKey); } async getNextNumber(guildId) { if (this.$numbers[guildId] === undefined) { const { _max: { number: max } } = await this.client.prisma.ticket.aggregate({ _max: { number: true }, where: { guildId }, }); this.client.tickets.$numbers[guildId] = max ?? 0; } this.$numbers[guildId] += 1; return this.$numbers[guildId]; } /** * @param {object} data * @param {string} data.categoryId * @param {import("discord.js").ChatInputCommandInteraction * | import("discord.js").ButtonInteraction * | import("discord.js").SelectMenuInteraction} data.interaction * @param {string?} [data.topic] */ async create({ categoryId, interaction, topic, referencesMessageId, referencesTicketId, }) { categoryId = Number(categoryId); const category = await this.getCategory(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, }); } /** @type {import("discord.js").Guild} */ const guild = this.client.guilds.cache.get(category.guild.id); const member = interaction.member ?? await guild.members.fetch(interaction.user.id); const getMessage = this.client.i18n.getLocale(category.guild.locale); const rlKey = `ratelimits/guild-user:${category.guildId}-${interaction.user.id}`; const rl = await this.client.keyv.get(rlKey); if (rl) { return await interaction.reply({ embeds: [ new ExtendedEmbedBuilder({ iconURL: guild.iconURL(), text: category.guild.footer, }) .setColor(category.guild.errorColour) .setTitle(getMessage('misc.ratelimited.title')) .setDescription(getMessage('misc.ratelimited.description')), ], ephemeral: true, }); } else { this.client.keyv.set(rlKey, true, ms('5s')); } const sendError = name => interaction.reply({ embeds: [ new ExtendedEmbedBuilder({ iconURL: guild.iconURL(), text: category.guild.footer, }) .setColor(category.guild.errorColour) .setTitle(getMessage(`misc.${name}.title`)) .setDescription(getMessage(`misc.${name}.description`)), ], ephemeral: true, }); if (category.guild.blocklist.length !== 0) { const blocked = category.guild.blocklist.some(r => member.roles.cache.has(r)); if (blocked) return await sendError('blocked'); } if (category.requiredRoles.length !== 0) { const missing = category.requiredRoles.some(r => !member.roles.cache.has(r)); if (missing) return await sendError('missing_roles'); } const discordCategory = guild.channels.cache.get(category.discordCategory); if (discordCategory.children.cache.size === 50) return await sendError('category_full'); const totalCount = await this.getTotalCount(category.id); if (totalCount >= category.totalLimit) return await sendError('category_full'); const memberCount = await this.getMemberCount(category.id, interaction.user.id); if (memberCount >= category.memberLimit) { return await interaction.reply({ embeds: [ new ExtendedEmbedBuilder({ iconURL: guild.iconURL(), text: category.guild.footer, }) .setColor(category.guild.errorColour) .setTitle(getMessage('misc.member_limit.title', memberCount, memberCount)) .setDescription(getMessage('misc.member_limit.description', memberCount)), ], ephemeral: true, }); } const cooldown = await this.getCooldown(category.id, interaction.user.id); if (cooldown) { return await interaction.reply({ embeds: [ new ExtendedEmbedBuilder({ iconURL: guild.iconURL(), text: category.guild.footer, }) .setColor(category.guild.errorColour) .setTitle(getMessage('misc.cooldown.title')) .setDescription(getMessage('misc.cooldown.description', { time: ms(cooldown - Date.now()) })), ], ephemeral: true, }); } if (category.questions.length >= 1) { await interaction.showModal( new ModalBuilder() .setCustomId(JSON.stringify({ action: 'questions', categoryId, referencesMessageId, referencesTicketId, })) .setTitle(category.name) .setComponents( category.questions .filter(q => q.type === 'TEXT') // TODO: remove this when modals support select menus .map(q => { if (q.type === 'TEXT') { const field = new TextInputBuilder() .setCustomId(q.id) .setLabel(q.label) .setStyle(q.style) .setMaxLength(Math.min(q.maxLength, 1000)) .setMinLength(q.minLength) .setPlaceholder(q.placeholder) .setRequired(q.required); if (q.value) field.setValue(q.value); return new ActionRowBuilder().setComponents(field); } else if (q.type === 'MENU') { return new ActionRowBuilder() .setComponents( new StringSelectMenuBuilder() .setCustomId(q.id) .setPlaceholder(q.placeholder || q.label) .setMaxValues(q.maxLength) .setMinValues(q.minLength) .setOptions( q.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; }), ), ); } }), ), ); } else if (category.requireTopic && !topic) { await interaction.showModal( new ModalBuilder() .setCustomId(JSON.stringify({ action: 'topic', categoryId, referencesMessageId, referencesTicketId, })) .setTitle(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), ), ), ); } else { await this.postQuestions({ categoryId, interaction, referencesMessageId, referencesTicketId, topic, }); } } /** * @param {object} data * @param {string} data.category * @param {import("discord.js").ButtonInteraction * | import("discord.js").SelectMenuInteraction * | import("discord.js").ModalSubmitInteraction} data.interaction * @param {string?} [data.topic] */ async postQuestions({ action, categoryId, interaction, topic, referencesMessageId, referencesTicketId, }) { const [, category] = await Promise.all([ interaction.deferReply({ ephemeral: true }), this.getCategory(categoryId), ]); let answers; if (interaction.isModalSubmit()) { if (action === 'questions') { 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'); } } /** @type {import("discord.js").Guild} */ const guild = this.client.guilds.cache.get(category.guild.id); const getMessage = this.client.i18n.getLocale(category.guild.locale); const creator = await guild.members.fetch(interaction.user.id); const number = await this.getNextNumber(category.guild.id); const channelName = category.channelName .replace(/{+\s?(user)?name\s?}+/gi, creator.user.username) .replace(/{+\s?(nick|display)(name)?\s?}+/gi, creator.displayName) .replace(/{+\s?num(ber)?\s?}+/gi, number === 1488 ? '1487b' : number); const allow = ['ViewChannel', 'ReadMessageHistory', 'SendMessages', 'EmbedLinks', 'AttachFiles']; /** @type {import("discord.js").TextChannel} */ const channel = await guild.channels.create({ name: channelName, parent: category.discordCategory, permissionOverwrites: [ { deny: ['ViewChannel'], id: guild.roles.everyone.id, }, { allow, id: this.client.user.id, }, { allow, id: creator.id, }, ...category.staffRoles.map(id => ({ allow, id, })), ], rateLimitPerUser: category.ratelimit, reason: `${creator.user.tag} created a ticket`, topic: `${creator}${topic?.length > 0 ? ` | ${topic}` : ''}`, }); const needsStats = /{+\s?(avgResponseTime|avgResolutionTime)\s?}+/i.test(category.openingMessage); const statsCacheKey = `cache/category-stats/${categoryId}`; let stats = await this.client.keyv.get(statsCacheKey); if (needsStats && !stats) { const closedTickets = await this.client.prisma.ticket.findMany({ select: { closedAt: true, createdAt: true, firstResponseAt: true, }, where: { categoryId: category.id, firstResponseAt: { not: null }, open: false, }, }); const { avgResolutionTime, avgResponseTime, } = await getAverageTimes(closedTickets); stats = { avgResolutionTime: ms(avgResolutionTime, { long: true }), avgResponseTime: ms(avgResponseTime, { long: true }), }; this.client.keyv.set(statsCacheKey, stats, ms('1h')); } const embeds = [ new ExtendedEmbedBuilder() .setColor(category.guild.primaryColour) .setAuthor({ iconURL: creator.displayAvatarURL(), name: creator.displayName, }) .setDescription( category.openingMessage .replace(/{+\s?(user)?name\s?}+/gi, creator.user.toString()) .replace(/{+\s?num(ber)?\s?}+/gi, number) .replace(/{+\s?avgResponseTime\s?}+/gi, stats?.avgResponseTime) .replace(/{+\s?avgResolutionTime\s?}+/gi, stats?.avgResolutionTime), ), ]; if (category.image) embeds[0].setImage(category.image); if (answers) { embeds.push( new ExtendedEmbedBuilder() .setColor(category.guild.primaryColour) .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() .setColor(category.guild.primaryColour) .setFields({ name: getMessage('ticket.opening_message.fields.topic'), value: topic, }), ); } if (category.guild.footer) { embeds[embeds.length - 1].setFooter({ iconURL: guild.iconURL(), text: category.guild.footer, }); } const components = new ActionRowBuilder(); if (topic || answers) { components.addComponents( new ButtonBuilder() .setCustomId(JSON.stringify({ action: 'edit' })) .setStyle(ButtonStyle.Secondary) .setEmoji(getMessage('buttons.edit.emoji')) .setLabel(getMessage('buttons.edit.text')), ); } if (category.guild.claimButton && category.claiming) { components.addComponents( new ButtonBuilder() .setCustomId(JSON.stringify({ action: 'claim' })) .setStyle(ButtonStyle.Secondary) .setEmoji(getMessage('buttons.claim.emoji')) .setLabel(getMessage('buttons.claim.text')), ); } if (category.guild.closeButton) { components.addComponents( new ButtonBuilder() .setCustomId(JSON.stringify({ action: 'close' })) .setStyle(ButtonStyle.Danger) .setEmoji(getMessage('buttons.close.emoji')) .setLabel(getMessage('buttons.close.text')), ); } const pings = category.pingRoles.map(r => `<@&${r}>`).join(' '); const sent = await channel.send({ components: components.components.length >= 1 ? [components] : [], content: getMessage('ticket.opening_message.content', { creator: interaction.user.toString(), staff: pings ? pings + ',' : '', }), embeds, }); await sent.pin({ reason: 'Ticket opening message' }); const pinned = channel.messages.cache.last(); if (pinned.system) { pinned .delete({ reason: 'Cleaning up system message' }) .catch(() => this.client.log.warn('Failed to delete system pin message')); } /** @type {import("discord.js").Message|undefined} */ let message; if (referencesMessageId) { /** @type {import("discord.js").Message} */ message = await interaction.channel.messages.fetch(referencesMessageId); if (message) { // not worth the effort of making system messages work atm if (message.system) { referencesMessageId = null; message = null; } else { if (!message.member) { try { message.member = await message.guild.members.fetch(message.author.id); } catch { this.client.log.verbose('Failed to fetch member %s of %s', message.author.id, message.guild.id); } } await channel.send({ embeds: [ new ExtendedEmbedBuilder() .setColor(category.guild.primaryColour) .setTitle(getMessage('ticket.references_message.title')) .setDescription( getMessage('ticket.references_message.description', { author: message.author.toString(), timestamp: ``, url: message.url, })), new ExtendedEmbedBuilder({ iconURL: guild.iconURL(), text: category.guild.footer, }) .setColor(category.guild.primaryColour) .setAuthor({ iconURL: message.member?.displayAvatarURL(), name: message.member?.displayName || 'Unknown', }) .setDescription(message.content.substring(0, 1000) + (message.content.length > 1000 ? '...' : '')), ], }); } } } else if (referencesTicketId) { // TODO: add portal url const ticket = await this.client.prisma.ticket.findUnique({ where: { id: referencesTicketId } }); if (ticket) { const embed = new ExtendedEmbedBuilder({ iconURL: guild.iconURL(), text: category.guild.footer, }) .setColor(category.guild.primaryColour) .setTitle(getMessage('ticket.references_ticket.title')) .setDescription(getMessage('ticket.references_ticket.description')) .setFields([ { inline: true, name: getMessage('ticket.references_ticket.fields.number'), value: inlineCode(ticket.number), }, { inline: true, name: getMessage('ticket.references_ticket.fields.date'), value: ``, }, ]); if (ticket.topic) { embed.addFields({ inline: false, name: getMessage('ticket.references_ticket.fields.topic'), value: await quick('crypto', worker => worker.decrypt(ticket.topic)), }); } await channel.send({ components: category.guild.archive ? [ new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId(JSON.stringify({ action: 'transcript', ticket: referencesTicketId, })) .setStyle(ButtonStyle.Primary) .setEmoji(getMessage('buttons.transcript.emoji')) .setLabel(getMessage('buttons.transcript.text')), ), ] : [], embeds: [embed], }); } } const data = { category: { connect: { id: categoryId } }, createdBy: { connectOrCreate: { create: { id: interaction.user.id }, where: { id: interaction.user.id }, }, }, guild: { connect: { id: category.guild.id } }, id: channel.id, number, openingMessageId: sent.id, topic: topic ? await quick('crypto', worker => worker.encrypt(topic)) : null, }; if (referencesTicketId) data.referencesTicket = { connect: { id: referencesTicketId } }; if (answers) data.questionAnswers = { createMany: { data: answers } }; await interaction.editReply({ components: [], embeds: [ new ExtendedEmbedBuilder({ iconURL: guild.iconURL(), text: category.guild.footer, }) .setColor(category.guild.successColour) .setTitle(getMessage('ticket.created.title')) .setDescription(getMessage('ticket.created.description', { channel: channel.toString() })), ], }); try { const ticket = await this.client.prisma.ticket.create({ data }); this.$count.categories[categoryId].total++; this.$count.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); } if (category.guild.archive && message) { 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: message.id }, where: { id: ticket.id }, }); } } logTicketEvent(this.client, { action: 'create', target: { id: ticket.id, name: channel.toString(), }, userId: interaction.user.id, }); } catch (error) { const ref = getSUID(); 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), }), ], }); } try { const workingHours = category.guild.workingHours; const timezone = workingHours[0]; workingHours.shift(); // remove timezone const now = spacetime.now(timezone); const currentHours = workingHours[now.day()]; const start = now.time(currentHours[0]); const end = now.time(currentHours[1]); let working = true; if (currentHours[0] === currentHours[1] || now.isAfter(end)) { // staff have the day off or have finished for the day // first look for the next working day *this* week (after today) let nextIndex = workingHours.findIndex((hours, i) => i > now.day() && hours[0] !== hours[1]); // if there isn't one, look for the next working day *next* week (before and including today's weekday) if (!nextIndex) nextIndex = workingHours.findIndex((hours, i) => i <= now.day() && hours[0] !== hours[1]); if (nextIndex) { working = false; const next = workingHours[nextIndex]; let then = now.add(nextIndex - now.day(), 'day'); if (nextIndex <= now.day()) then = then.add(1, 'week'); const timestamp = Math.ceil(then.time(next[0]).goto('utc').d.getTime() / 1000); // in seconds await channel.send({ embeds: [ new ExtendedEmbedBuilder() .setColor(category.guild.primaryColour) .setTitle(getMessage('ticket.working_hours.next.title')) .setDescription(getMessage('ticket.working_hours.next.description', { timestamp })), ], }); } } else if (now.isBefore(start)) { // staff haven't started working yet working = false; const timestamp = Math.ceil(start.goto('utc').d.getTime() / 1000); // in seconds await channel.send({ embeds: [ new ExtendedEmbedBuilder() .setColor(category.guild.primaryColour) .setTitle(getMessage('ticket.working_hours.today.title')) .setDescription(getMessage('ticket.working_hours.today.description', { timestamp })), ], }); } if (working && process.env.PUBLIC_BOT !== 'true') { let online = 0; for (const [, member] of channel.members) { if (!await isStaff(channel.guild, member.id)) continue; if (member.presence && member.presence !== 'offline') online++; } if (online === 0) { await channel.send({ embeds: [ new ExtendedEmbedBuilder() .setColor(category.guild.primaryColour) .setTitle(getMessage('ticket.offline.title')) .setDescription(getMessage('ticket.offline.description')), ], }); this.client.keyv.set(`offline/${channel.id}`, Date.now(), ms('1h')); } } } catch (error) { this.client.log.error(error); } } /** * @param {import("discord.js").ChatInputCommandInteraction|import("discord.js").ButtonInteraction} interaction */ async claim(interaction) { const ticket = await this.client.prisma.ticket.findUnique({ include: { _count: { select: { questionAnswers: true } }, category: true, guild: true, }, where: { id: interaction.channel.id }, }); const getMessage = this.client.i18n.getLocale(ticket.guild.locale); if (!(await isStaff(interaction.guild, interaction.user.id))) { // if user is not staff return await interaction.reply({ embeds: [ new ExtendedEmbedBuilder({ iconURL: interaction.guild.iconURL(), text: ticket.guild.footer, }) .setColor(ticket.guild.errorColour) .setTitle(getMessage('commands.slash.claim.not_staff.title')) .setDescription(getMessage('commands.slash.claim.not_staff.description')), ], ephemeral: true, }); } await interaction.deferReply({ ephemeral: false }); 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 }, }), ]); const openingMessage = await interaction.channel.messages.fetch(ticket.openingMessageId); if (openingMessage && openingMessage.components.length !== 0) { const components = new ActionRowBuilder(); if (ticket.topic || ticket._count.questionAnswers !== 0) { components.addComponents( new ButtonBuilder() .setCustomId(JSON.stringify({ action: 'edit' })) .setStyle(ButtonStyle.Secondary) .setEmoji(getMessage('buttons.edit.emoji')) .setLabel(getMessage('buttons.edit.text')), ); } if (ticket.guild.claimButton && ticket.category.claiming) { components.addComponents( new ButtonBuilder() .setCustomId(JSON.stringify({ action: 'unclaim' })) .setStyle(ButtonStyle.Secondary) .setEmoji(getMessage('buttons.unclaim.emoji')) .setLabel(getMessage('buttons.unclaim.text')), ); } if (ticket.guild.closeButton) { components.addComponents( new ButtonBuilder() .setCustomId(JSON.stringify({ action: 'close' })) .setStyle(ButtonStyle.Danger) .setEmoji(getMessage('buttons.close.emoji')) .setLabel(getMessage('buttons.close.text')), ); } await openingMessage.edit({ components: [components] }); } await interaction.editReply({ embeds: [ new ExtendedEmbedBuilder() .setColor(ticket.guild.primaryColour) .setDescription(getMessage('ticket.claimed', { user: interaction.user.toString() })), ], }); logTicketEvent(this.client, { action: 'claim', target: { id: ticket.id, name: interaction.channel.toString(), }, userId: interaction.user.id, }); } /** * @param {import("discord.js").ChatInputCommandInteraction|import("discord.js").ButtonInteraction} interaction */ async release(interaction) { const ticket = await this.client.prisma.ticket.findUnique({ include: { _count: { select: { questionAnswers: true } }, category: true, guild: true, }, where: { id: interaction.channel.id }, }); const getMessage = this.client.i18n.getLocale(ticket.guild.locale); if (!(await isStaff(interaction.guild, interaction.user.id))) { // if user is not staff return await interaction.reply({ embeds: [ new ExtendedEmbedBuilder({ iconURL: interaction.guild.iconURL(), text: ticket.guild.footer, }) .setColor(ticket.guild.errorColour) .setTitle(getMessage('commands.slash.claim.not_staff.title')) .setDescription(getMessage('commands.slash.claim.not_staff.description')), ], ephemeral: true, }); } await interaction.deferReply({ ephemeral: false }); 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); if (openingMessage && openingMessage.components.length !== 0) { const components = new ActionRowBuilder(); if (ticket.topic || ticket._count.questionAnswers !== 0) { components.addComponents( new ButtonBuilder() .setCustomId(JSON.stringify({ action: 'edit' })) .setStyle(ButtonStyle.Secondary) .setEmoji(getMessage('buttons.edit.emoji')) .setLabel(getMessage('buttons.edit.text')), ); } if (ticket.guild.claimButton && ticket.category.claiming) { components.addComponents( new ButtonBuilder() .setCustomId(JSON.stringify({ action: 'claim' })) .setStyle(ButtonStyle.Secondary) .setEmoji(getMessage('buttons.claim.emoji')) .setLabel(getMessage('buttons.claim.text')), ); } if (ticket.guild.closeButton) { components.addComponents( new ButtonBuilder() .setCustomId(JSON.stringify({ action: 'close' })) .setStyle(ButtonStyle.Danger) .setEmoji(getMessage('buttons.close.emoji')) .setLabel(getMessage('buttons.close.text')), ); } await openingMessage.edit({ components: [components] }); } await interaction.editReply({ embeds: [ new ExtendedEmbedBuilder() .setColor(ticket.guild.primaryColour) .setDescription(getMessage('ticket.released', { user: interaction.user.toString() })), ], }); logTicketEvent(this.client, { action: 'unclaim', target: { id: ticket.id, name: interaction.channel.toString(), }, userId: interaction.user.id, }); } buildFeedbackModal(locale, id) { const getMessage = this.client.i18n.getLocale(locale); return new ModalBuilder() .setCustomId(JSON.stringify({ action: 'feedback', ...id, })) .setTitle(getMessage('modals.feedback.title')) .setComponents( new ActionRowBuilder() .setComponents( new TextInputBuilder() .setCustomId('rating') .setLabel(getMessage('modals.feedback.rating.label')) .setStyle(TextInputStyle.Short) .setMaxLength(3) .setMinLength(1) .setPlaceholder(getMessage('modals.feedback.rating.placeholder')) .setRequired(true), ), new ActionRowBuilder() .setComponents( new TextInputBuilder() .setCustomId('comment') .setLabel(getMessage('modals.feedback.comment.label')) .setStyle(TextInputStyle.Paragraph) .setMaxLength(1000) .setMinLength(4) .setPlaceholder(getMessage('modals.feedback.comment.placeholder')) .setRequired(false), ), ); } /** * @param {import("discord.js").ChatInputCommandInteraction|import("discord.js").ButtonInteraction} interaction */ async beforeRequestClose(interaction) { 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: { errorColour: true, locale: true, }, where: { id: interaction.guild.id }, }); const getMessage = this.client.i18n.getLocale(locale); return await interaction.editReply({ embeds: [ new ExtendedEmbedBuilder({ iconURL: interaction.guild.iconURL(), text: footer, }) .setColor(errorColour) .setTitle(getMessage('misc.not_ticket.title')) .setDescription(getMessage('misc.not_ticket.description')), ], }); } const getMessage = this.client.i18n.getLocale(ticket.guild.locale); const staff = await isStaff(interaction.guild, interaction.user.id); const reason = interaction.options?.getString('reason', false) || null; // ?. because it could be a button interaction if (ticket.createdById !== interaction.user.id && !staff) { return await interaction.editReply({ embeds: [ new ExtendedEmbedBuilder() .setColor(ticket.guild.errorColour) .setTitle(getMessage('ticket.close.forbidden.title')) .setDescription(getMessage('ticket.close.forbidden.description')), ], }); } if ( ticket.createdById === interaction.user.id && ticket.category.enableFeedback && !ticket.feedback ) { 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 custom_id limit })); } // not showing feedback, so send the close request // defer asap await interaction.deferReply(); // if the creator isn't in the guild , close the ticket immediately // (although leaving should cause the ticket to be closed anyway) try { await interaction.guild.members.fetch(ticket.createdById); } catch { return this.finallyClose(ticket.id, { reason }); } this.requestClose(interaction, reason); } /** * @param {import("discord.js").ChatInputCommandInteraction * | import("discord.js").ButtonInteraction * | import("discord.js").ModalSubmitInteraction} interaction * @param {string} reason */ async requestClose(interaction, reason) { // interaction could be command, button. or modal const ticket = await this.getTicket(interaction.channel.id); const getMessage = this.client.i18n.getLocale(ticket.guild.locale); const staff = interaction.user.id !== ticket.createdById && await isStaff(interaction.guild, interaction.user.id); const closeButtonId = { action: 'close', expect: staff ? 'user' : 'staff', }; const embed = new ExtendedEmbedBuilder(/* { iconURL: interaction.guild.iconURL(), text: ticket.guild.footer, } */) .setColor(ticket.guild.primaryColour) .setTitle(getMessage(`ticket.close.${staff ? 'staff' : 'user'}_request.title`, { requestedBy: interaction.member.displayName })); if (staff) { embed.setDescription( getMessage('ticket.close.staff_request.description', { requestedBy: interaction.user.toString() }) + (ticket.guild.archive ? getMessage('ticket.close.staff_request.archived') : ''), ); } const sent = await interaction.editReply({ components: [ new ActionRowBuilder() .addComponents( new ButtonBuilder() .setCustomId(JSON.stringify({ accepted: true, ...closeButtonId, })) .setStyle(ButtonStyle.Success) .setEmoji(getMessage('buttons.accept_close_request.emoji')) .setLabel(getMessage('buttons.accept_close_request.text')), new ButtonBuilder() .setCustomId(JSON.stringify({ accepted: false, ...closeButtonId, })) .setStyle(ButtonStyle.Danger) .setEmoji(getMessage('buttons.reject_close_request.emoji')) .setLabel(getMessage('buttons.reject_close_request.text')), ), ], content: staff ? `<@${ticket.createdById}>` : '', // ticket.category.pingRoles.map(r => `<@&${r}>`).join(' ') embeds: [embed], }); this.$stale.set(ticket.id, { closeAt: ticket.guild.autoClose ? Date.now() + ticket.guild.autoClose : null, closedBy: interaction.user.id, // null if set as stale due to inactivity message: sent, messages: 0, reason, staleSince: Date.now(), }); if (ticket.priority && ticket.priority !== 'LOW') { await this.client.prisma.ticket.update({ data: { priority: 'LOW' }, where: { id: ticket.id }, }); } } /** * @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); 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, this.$stale.get(interaction.channel.id) || {}); } /** * close a ticket * @param {string} ticketId */ async finallyClose(ticketId, { closedBy = null, reason = null, }) { if (this.$stale.has(ticketId)) this.$stale.delete(ticketId); let ticket = await this.getTicket(ticketId); const getMessage = this.client.i18n.getLocale(ticket.guild.locale); 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 && await quick('crypto', worker => worker.encrypt(reason)), messageCount: archivedMessages, open: false, }; /** @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()]; } ticket = await this.client.prisma.ticket.update({ data, include: { category: true, feedback: true, guild: true, }, 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}` : ''); } logTicketEvent(this.client, { action: 'close', target: { id: ticket.id, name: `${ticket.category.name} **#${ticket.number}**`, }, userId: closedBy || this.client.user.id, }); try { const creator = channel?.guild.members.cache.get(ticket.createdById); if (creator) { const embed = new ExtendedEmbedBuilder({ iconURL: channel.guild.iconURL(), text: ticket.guild.footer, }) .setColor(ticket.guild.primaryColour) .setTitle(getMessage('dm.closed.title')) .addFields([ { inline: true, name: getMessage('dm.closed.fields.ticket'), value: `${ticket.category.name} **#${ticket.number}**`, }, ]); if (ticket.topic) { embed.addFields({ inline: true, name: getMessage('dm.closed.fields.topic'), value: await quick('crypto', worker => worker.decrypt(ticket.topic)), }); } embed.addFields([ { inline: true, name: getMessage('dm.closed.fields.created'), value: ``, }, { inline: true, name: getMessage('dm.closed.fields.closed.name'), value: getMessage('dm.closed.fields.closed.value', { duration: ms(ticket.closedAt - ticket.createdAt, { long: true }), timestamp: ``, }), }, ]); if (ticket.firstResponseAt) { embed.addFields({ inline: true, name: getMessage('dm.closed.fields.response'), value: ms(ticket.firstResponseAt - ticket.createdAt, { long: true }), }); } if (ticket.feedback) { embed.addFields({ inline: true, name: getMessage('dm.closed.fields.feedback'), value: Array(ticket.feedback.rating).fill('⭐').join(' ') + ` (${ticket.feedback.rating}/5)`, }); } if (ticket.closedById) { embed.addFields({ inline: true, name: getMessage('dm.closed.fields.closed_by'), value: `<@${ticket.closedById}>`, }); } if (reason) { embed.addFields({ inline: true, name: getMessage('dm.closed.fields.reason'), value: reason, }); } if (ticket.guild.archive) embed.setDescription(getMessage('dm.closed.archived', { guild: channel.guild.name })); await creator.send({ embeds: [embed] }); } } catch (error) { this.client.log.error(error); } } };