feat: feedback, start of close requests

This commit is contained in:
Isaac 2023-01-13 20:48:37 +00:00
parent d7e1b05586
commit 8bf01aa520
No known key found for this signature in database
GPG Key ID: 0DE40AE37BBA5C33
16 changed files with 293 additions and 59 deletions

View File

@ -101,10 +101,9 @@ model Feedback {
createdAt DateTime @default(now())
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String @db.VarChar(19)
id Int @id @default(autoincrement())
rating Int
ticket Ticket @relation(fields: [ticketId], references: [id])
ticketId String @unique @db.VarChar(19)
ticketId String @id @db.VarChar(19)
user User? @relation(fields: [userId], references: [id])
userId String? @db.VarChar(19)
@ -199,10 +198,9 @@ model Ticket {
createdAt DateTime @default(now())
createdBy User @relation(name: "TicketsCreatedByUser", fields: [createdById], references: [id])
createdById String @db.VarChar(19)
feedback Feedback?
feedbackId Int?
firstResponseAt DateTime?
deleted Boolean @default(false)
feedback Feedback?
firstResponseAt DateTime?
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String @db.VarChar(19)
id String @id @db.VarChar(19)

View File

@ -101,10 +101,9 @@ model Feedback {
createdAt DateTime @default(now())
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String @db.VarChar(19)
id Int @id @default(autoincrement())
rating Int
ticket Ticket @relation(fields: [ticketId], references: [id])
ticketId String @unique @db.VarChar(19)
ticketId String @id @db.VarChar(19)
user User? @relation(fields: [userId], references: [id])
userId String? @db.VarChar(19)
@ -199,10 +198,9 @@ model Ticket {
createdAt DateTime @default(now())
createdBy User @relation(name: "TicketsCreatedByUser", fields: [createdById], references: [id])
createdById String @db.VarChar(19)
feedback Feedback?
feedbackId Int?
firstResponseAt DateTime?
deleted Boolean @default(false)
feedback Feedback?
firstResponseAt DateTime?
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String @db.VarChar(19)
id String @id @db.VarChar(19)

View File

@ -101,10 +101,9 @@ model Feedback {
createdAt DateTime @default(now())
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String
id Int @id @default(autoincrement())
rating Int
ticket Ticket @relation(fields: [ticketId], references: [id])
ticketId String @unique
ticketId String @id
user User? @relation(fields: [userId], references: [id])
userId String?
@ -199,10 +198,9 @@ model Ticket {
createdAt DateTime @default(now())
createdBy User @relation(name: "TicketsCreatedByUser", fields: [createdById], references: [id])
createdById String
feedback Feedback?
feedbackId Int?
firstResponseAt DateTime?
deleted Boolean @default(false)
feedback Feedback?
firstResponseAt DateTime?
guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade)
guildId String
id String @id

View File

@ -43,8 +43,8 @@
"@fastify/http-proxy": "^8.4.0",
"@fastify/jwt": "^5.0.1",
"@fastify/oauth2": "^5.1.0",
"@prisma/client": "^4.8.0",
"cryptr": "^6.0.3",
"@prisma/client": "^4.8.1",
"cryptr": "^6.1.0",
"discord.js": "^14.7.1",
"dotenv": "^16.0.3",
"express": "^4.18.2",
@ -60,7 +60,7 @@
"node-emoji": "^1.11.0",
"object-diffy": "^1.0.4",
"pad": "^3.2.0",
"prisma": "^4.8.0",
"prisma": "^4.8.1",
"semver": "^7.3.8",
"terminal-link": "^2.1.1",
"yaml": "^1.10.2"

View File

@ -1,4 +1,5 @@
const { Button } = require('@eartharoid/dbf');
const { isStaff } = require('../lib/users');
module.exports = class CloseButton extends Button {
constructor(client, options) {
@ -16,6 +17,29 @@ module.exports = class CloseButton extends Button {
/** @type {import("client")} */
const client = this.client;
await interaction.deferReply();
if (id.accepted === undefined) {
await client.tickets.beforeRequestClose(interaction);
} else {
// {
// action: 'close',
// expect: staff ? 'user' : 'staff',
// reason: interaction.options?.getString('reason', false) || null, // ?. because it could be a button interaction
// requestedBy: interaction.user.id,
// }
await interaction.deferReply();
const ticket = await client.prisma.ticket.findUnique({
include: { guild: true },
where: { id: interaction.channel.id },
});
if (id.expect === 'staff' && !await isStaff(interaction.guild, interaction.user.id)) {
return;
} else if (interaction.user.id !== ticket.createdById) {
return;
// if user and expect user (or is creator), feedback modal (if enabled)
// otherwise add "Give feedback" button in DM message (if enabled)
}
}
}
};

View File

@ -2,8 +2,8 @@ const { Button } = require('@eartharoid/dbf');
const {
ActionRowBuilder,
ModalBuilder,
SelectMenuBuilder,
SelectMenuOptionBuilder,
StringSelectMenuBuilder,
StringSelectMenuOptionBuilder,
TextInputBuilder,
TextInputStyle,
} = require('discord.js');
@ -86,14 +86,14 @@ module.exports = class EditButton extends Button {
} else if (a.question.type === 'MENU') {
return new ActionRowBuilder()
.setComponents(
new SelectMenuBuilder()
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 SelectMenuOptionBuilder()
const builder = new StringSelectMenuOptionBuilder()
.setValue(String(i))
.setLabel(o.label);
if (o.description) builder.setDescription(o.description);

View File

@ -1,6 +1,7 @@
const { FrameworkClient } = require('@eartharoid/dbf');
const {
GatewayIntentBits, Partials,
GatewayIntentBits,
Partials,
} = require('discord.js');
const { PrismaClient } = require('@prisma/client');
const Keyv = require('keyv');

View File

@ -30,6 +30,8 @@ module.exports = class CloseSlashCommand extends SlashCommand {
* @param {import("discord.js").ChatInputCommandInteraction} interaction
*/
async run(interaction) {
/** @type {import("client")} */
const client = this.client;
await client.tickets.beforeRequestClose(interaction);
}
};

View File

@ -228,7 +228,5 @@ module.exports = class ForceCloseSlashCommand extends SlashCommand {
});
}
}
// TODO: close (reason)
}
};

View File

@ -85,8 +85,8 @@ module.exports = class MoveSlashCommand extends SlashCommand {
where: { id: ticket.id },
});
const $oldCategory = client.tickets.$.categories[ticket.categoryId];
const $newCategory = client.tickets.$.categories[newCategory.id];
const $oldCategory = client.tickets.$count.categories[ticket.categoryId];
const $newCategory = client.tickets.$count.categories[newCategory.id];
$oldCategory.total--;
$oldCategory[ticket.createdById]--;

View File

@ -44,7 +44,7 @@ module.exports = class TransferSlashCommand extends SlashCommand {
let ticket = await client.prisma.ticket.findUnique({ where: { id: interaction.channel.id } });
const from = ticket.createdById;
console.log(1)
ticket = await client.prisma.ticket.update({
data: {
createdBy: {

View File

@ -1,6 +1,9 @@
buttons:
accept_close_request:
emoji:
text: Accept
cancel:
emoji: 🚫
emoji: ✖️
text: Cancel
claim:
emoji: 🙌
@ -17,6 +20,9 @@ buttons:
edit:
emoji: ✏️
text: Edit
reject_close_request:
emoji: ✖️
text: Reject
unclaim:
emoji: ♻️
text: Release
@ -328,7 +334,13 @@ misc:
title: ❌ That ticket category doesn't exist
modals:
feedback:
title: Feedback
comment:
label: Comment
placeholder: Do you have any additional feedback?
rating:
label: Rating
placeholder: 1-5
title: How did we do?
topic:
label: Topic
placeholder: What is this ticket about?
@ -336,6 +348,20 @@ ticket:
answers:
no_value: "*No response*"
claimed: 🙌 {user} has claimed this ticket.
close:
forbidden:
description: You don't have permission to close this ticket.
title: ❌ Error
staff_request:
archived: |
The messages in this channel will be archived for future reference.
description: |
{requestedBy} wants to close this ticket.
Click "Accept" to close it now, or "Reject" if you still need help.
title: ❓ Can this ticket be closed?
user_request:
title: ❓ {requestedBy} wants to close this ticket
created:
description: "Your ticket channel has been created: {channel}."
title: ✅ Ticket created

View File

@ -22,10 +22,10 @@ module.exports = async client => {
let cooldowns = 0;
for (const category of categories) {
ticketCount += category.tickets.length;
client.tickets.$.categories[category.id] = { total: category.tickets.length };
client.tickets.$count.categories[category.id] = { total: category.tickets.length };
for (const ticket of category.tickets) {
if (client.tickets.$.categories[category.id][ticket.createdById]) client.tickets.$.categories[category.id][ticket.createdById]++;
else client.tickets.$.categories[category.id][ticket.createdById] = 1;
if (client.tickets.$count.categories[category.id][ticket.createdById]) client.tickets.$count.categories[category.id][ticket.createdById]++;
else client.tickets.$count.categories[category.id][ticket.createdById] = 1;
/** @type {import("discord.js").Guild} */
const guild = client.guilds.cache.get(ticket.guildId);
if (guild && guild.available && !client.channels.cache.has(ticket.id)) {

View File

@ -16,6 +16,7 @@ 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 Cryptr = require('cryptr');
const { encrypt } = new Cryptr(process.env.ENCRYPTION_KEY);
@ -30,14 +31,21 @@ module.exports = class TicketManager {
/** @type {import("client")} */
this.client = client;
this.archiver = new TicketArchiver(client);
this.$ = { categories: {} };
this.$count = { categories: {} };
this.$stale = new Collection();
}
async getCategory(categoryId) {
/**
* 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) {
if (!category || force) {
category = await this.client.prisma.category.findUnique({
include: {
guild: true,
@ -45,16 +53,16 @@ module.exports = class TicketManager {
},
where: { id: categoryId },
});
this.client.keyv.set(cacheKey, category, ms('5m'));
await this.client.keyv.set(cacheKey, category, ms('12h'));
}
return category;
}
// TODO: update when a ticket is closed or moved
async getTotalCount(categoryId) {
const category = this.$.categories[categoryId];
if (!category) this.$.categories[categoryId] = {};
let count = this.$.categories[categoryId].total;
const category = this.$count.categories[categoryId];
if (!category) this.$count.categories[categoryId] = {};
let count = this.$count.categories[categoryId].total;
if (!count) {
count = await this.client.prisma.ticket.count({
where: {
@ -62,16 +70,16 @@ module.exports = class TicketManager {
open: true,
},
});
this.$.categories[categoryId].total = count;
this.$count.categories[categoryId].total = count;
}
return count;
}
// TODO: update when a ticket is closed or moved
async getMemberCount(categoryId, memberId) {
const category = this.$.categories[categoryId];
if (!category) this.$.categories[categoryId] = {};
let count = this.$.categories[categoryId][memberId];
const category = this.$count.categories[categoryId];
if (!category) this.$count.categories[categoryId] = {};
let count = this.$count.categories[categoryId][memberId];
if (!count) {
count = await this.client.prisma.ticket.count({
where: {
@ -80,7 +88,7 @@ module.exports = class TicketManager {
open: true,
},
});
this.$.categories[categoryId][memberId] = count;
this.$count.categories[categoryId][memberId] = count;
}
return count;
}
@ -308,17 +316,15 @@ module.exports = class TicketManager {
}) {
await interaction.deferReply({ ephemeral: true });
const cacheKey = `cache/category+guild+questions:${categoryId}`;
/** @type {CategoryGuildQuestions} */
const category = await this.client.keyv.get(cacheKey);
const category = await this.getCategory(categoryId);
let answers;
if (interaction.isModalSubmit()) {
if (action === 'questions') {
answers = category.questions.map(q => ({
answers = category.questions.filter(q => q.type === 'TEXT').map(q => ({
questionId: q.id,
userId: interaction.user.id,
value: interaction.fields.getTextInputValue(q.id) ? cryptr.encrypt(interaction.fields.getTextInputValue(q.id)) : '',
value: interaction.fields.getTextInputValue(q.id) ? encrypt(interaction.fields.getTextInputValue(q.id)) : '',
}));
if (category.customTopic) topic = interaction.fields.getTextInputValue(category.customTopic);
} else if (action === 'topic') {
@ -568,7 +574,7 @@ module.exports = class TicketManager {
id: channel.id,
number,
openingMessageId: sent.id,
topic: topic ? cryptr.encrypt(topic) : null,
topic: topic ? encrypt(topic) : null,
};
if (referencesTicketId) data.referencesTicket = { connect: { id: referencesTicketId } };
if (answers) data.questionAnswers = { createMany: { data: answers } };
@ -587,8 +593,8 @@ module.exports = class TicketManager {
try {
const ticket = await this.client.prisma.ticket.create({ data });
this.$.categories[categoryId].total++;
this.$.categories[categoryId][creator.id]++;
this.$count.categories[categoryId].total++;
this.$count.categories[categoryId][creator.id]++;
if (category.cooldown) {
const cacheKey = `cooldowns/category-member:${category.id}-${ticket.createdById}`;
@ -805,15 +811,170 @@ module.exports = class TicketManager {
/**
* @param {import("discord.js").ChatInputCommandInteraction|import("discord.js").ButtonInteraction} interaction
*/
async requestClose(interaction) {
async beforeRequestClose(interaction) {
const ticket = await this.client.prisma.ticket.findUnique({
include: {
category: true,
category: { select: { enableFeedback: true } },
feedback: { select: { id: true } },
guild: true,
},
where: { id: interaction.channel.id },
});
if (!ticket) {
await interaction.deferReply({ ephemeral: true });
const {
errorColour,
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()
.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(
new ModalBuilder()
.setCustomId(JSON.stringify({
action: 'feedback',
reason,
}))
.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(false),
),
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),
),
),
);
}
// 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.close(ticket.id, true, reason);
}
await this.requestClose(interaction, reason);
}
/**
* @param {import("discord.js").ChatInputCommandInteraction|import("discord.js").ButtonInteraction|import("discord.js").ModalSubmitInteraction} interaction
*/
async requestClose(interaction, reason) {
// interaction could be command, button. or modal
const ticket = await this.client.prisma.ticket.findUnique({
include: { guild: true },
where: { id: interaction.channel.id },
});
const getMessage = this.client.i18n.getLocale(ticket.guild.locale);
const staff = await isStaff(interaction.guild, interaction.user.id);
const closeButtonId = {
action: 'close',
expect: staff ? 'user' : 'staff',
};
const embed = new ExtendedEmbedBuilder()
.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,
reason,
staleSince: Date.now(),
});
if (ticket.priority && ticket.priority !== 'LOW') {
await this.client.prisma.ticket.update({
data: { priority: 'LOW' },
where: { id: ticket.id },
});
}
}
/**
@ -822,10 +983,11 @@ module.exports = class TicketManager {
* @param {boolean} skip
* @param {string} reason
*/
async final(ticketId, skip, reason) {
async close(ticketId, skip, reason) {
// TODO: update cache/cat count
// TODO: update cache/member count
// TODO: set messageCount on ticket
// TODO: pinnedMessages, closedBy, closedAt
// delete
}
};

View File

@ -108,5 +108,13 @@ module.exports = class extends Listener {
send();
setInterval(() => send(), ms('12h'));
}
setInterval(() => {
// TODO: check lastMessageAt and set stale
for (const [ticketId, $] of client.tickets.$stale) {
// ⌛
}
}, ms('5m'));
}
};

View File

@ -8,5 +8,24 @@ module.exports = class FeedbackModal extends Modal {
});
}
async run(id, interaction) { }
async run(id, interaction) {
/** @type {import("client")} */
const client = this.client;
await interaction.deferReply();
await client.prisma.ticket.update({
data: {
feedback: {
create: {
comment: interaction.fields.getTextInputValue('comment'),
guild: { connect: { id: interaction.guild.id } },
rating: parseInt(interaction.fields.getTextInputValue('rating')) || null,
user: { connect: { id: interaction.user.id } },
},
},
},
where: { id: interaction.channel.id },
});
await client.tickets.requestClose(interaction, id.reason);
}
};