mirror of
https://github.com/Hessenuk/DiscordTickets.git
synced 2025-04-05 04:41:41 +03:00
1354 lines
41 KiB
JavaScript
1354 lines
41 KiB
JavaScript
/* 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<CategoryGuildQuestions>}
|
|
*/
|
|
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<TicketCategoryFeedbackGuild>}
|
|
*/
|
|
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: `<t:${Math.ceil(message.createdTimestamp / 1000)}:R>`,
|
|
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: `<t:${Math.ceil(ticket.createdAt / 1000)}:f>`,
|
|
},
|
|
]);
|
|
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: `<t:${Math.floor(ticket.createdAt / 1000)}:f>`,
|
|
},
|
|
{
|
|
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: `<t:${Math.floor(ticket.closedAt / 1000)}:f>`,
|
|
}),
|
|
},
|
|
]);
|
|
|
|
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);
|
|
}
|
|
|
|
}
|
|
};
|