diff --git a/package.json b/package.json index 63510b7..629774e 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "keyv": "^4.0.3", "leeks.js": "^0.2.2", "leekslazylogger-fastify": "^0.1.1", + "mustache": "^4.2.0", "node-emoji": "^1.10.0", "node-fetch": "^2.6.1", "semver": "^7.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2652f22..6347df5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,7 @@ specifiers: leeks.js: ^0.2.2 leekslazylogger-fastify: ^0.1.1 mariadb: ^2.5.2 + mustache: ^4.2.0 mysql2: ^2.2.5 node-emoji: ^1.10.0 node-fetch: ^2.6.1 @@ -38,6 +39,7 @@ dependencies: keyv: 4.0.3 leeks.js: 0.2.2 leekslazylogger-fastify: 0.1.1 + mustache: 4.2.0 node-emoji: 1.10.0 node-fetch: 2.6.1 semver: 7.3.4 @@ -1625,6 +1627,11 @@ packages: /ms/2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + /mustache/4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + dev: false + /mysql2/2.2.5: resolution: {integrity: sha512-XRqPNxcZTpmFdXbJqb+/CtYVLCx14x1RTeNMD4954L331APu75IC74GDqnZMEt1kwaXy6TySo55rF2F3YJS78g==} engines: {node: '>= 8.0'} diff --git a/src/commands/blacklist.js b/src/commands/blacklist.js index bff6938..dac17ef 100644 --- a/src/commands/blacklist.js +++ b/src/commands/blacklist.js @@ -82,7 +82,7 @@ module.exports = class BlacklistCommand extends Command { const member_or_role = is_role ? 'role' : 'member'; const index = settings.blacklist.findIndex(element => element === id); - const new_blacklist = [ ...settings.blacklist ]; + const new_blacklist = [...settings.blacklist]; if (index === -1) { new_blacklist.push(id); diff --git a/src/settings.schema.json b/src/commands/extra/settings.schema.json similarity index 98% rename from src/settings.schema.json rename to src/commands/extra/settings.schema.json index 73c07f4..2af95d9 100644 --- a/src/settings.schema.json +++ b/src/commands/extra/settings.schema.json @@ -66,7 +66,6 @@ }, "required": [ "name", - "name_format", "opening_message", "roles" ] diff --git a/src/commands/extra/survey.template.html b/src/commands/extra/survey.template.html new file mode 100644 index 0000000..a20ed5a --- /dev/null +++ b/src/commands/extra/survey.template.html @@ -0,0 +1,67 @@ + + + + + {{survey}} Survey Responses | Discord Tickets + + + + + + + + + +
+ +
+

{{survey}} survey responses

+
+ +
+
+
+

{{count.responses}}

+

Responses

+
+
+
+
+

{{count.users}}

+

Users

+
+
+
+ +
+ + + + {{#columns}} + + {{/columns}} + + + + {{#responses}} + + {{#.}} + + {{/.}} + + {{/responses}} + + + + {{#columns}} + + {{/columns}} + + +
{{.}}
{{.}}
{{.}}
+
+
+
+ + + \ No newline at end of file diff --git a/src/commands/settings.js b/src/commands/settings.js index b40c487..3333317 100644 --- a/src/commands/settings.js +++ b/src/commands/settings.js @@ -19,8 +19,7 @@ module.exports = class SettingsCommand extends Command { permissions: ['MANAGE_GUILD'] }); - this.schema = require('../settings.schema.json'); - + this.schema = require('./extra/settings.schema.json'); this.v = new Validator(); } @@ -33,7 +32,7 @@ module.exports = class SettingsCommand extends Command { const settings = await message.guild.settings; const i18n = this.client.i18n.getLocale(settings.locale); - const attachments = [ ...message.attachments.values() ]; + const attachments = [...message.attachments.values()]; if (attachments.length >= 1) { @@ -207,7 +206,7 @@ module.exports = class SettingsCommand extends Command { `Settings for ${message.guild.name}.json` ); - message.channel.send({ + return await message.channel.send({ files: [attachment] }); } diff --git a/src/commands/survey.js b/src/commands/survey.js new file mode 100644 index 0000000..d6ffdd7 --- /dev/null +++ b/src/commands/survey.js @@ -0,0 +1,112 @@ +const Command = require('../modules/commands/command'); +// eslint-disable-next-line no-unused-vars +const { MessageAttachment, MessageEmbed, Message } = require('discord.js'); +const fsp = require('fs').promises; +const { path } = require('../utils/fs'); +const mustache = require('mustache'); + +module.exports = class SurveyCommand extends Command { + constructor(client) { + const i18n = client.i18n.getLocale(client.config.locale); + super(client, { + internal: true, + name: i18n('commands.survey.name'), + description: i18n('commands.survey.description'), + aliases: [ + i18n('commands.survey.aliases.surveys') + ], + process_args: false, + args: [ + { + name: i18n('commands.survey.args.survey.name'), + description: i18n('commands.survey.args.survey.description'), + example: i18n('commands.survey.args.survey.example'), + required: false, + } + ], + staff_only: true + }); + } + + /** + * @param {Message} message + * @param {string} args + * @returns {Promise} + */ + async execute(message, args) { + const settings = await message.guild.settings; + const i18n = this.client.i18n.getLocale(settings.locale); + + const survey = await this.client.db.models.Survey.findOne({ + where: { + name: args, + guild: message.guild.id + } + }); + + if (survey) { + const { rows: responses, count } = await this.client.db.models.SurveyResponse.findAndCountAll({ + where: { + survey: survey.id + } + }); + + const users = new Set(); + + + for (const i in responses) { + const ticket = await this.client.db.models.Ticket.findOne({ + where: { + id: responses[i].ticket + } + }); + users.add(ticket.creator); + const answers = responses[i].answers.map(a => this.client.cryptr.decrypt(a)); + answers.unshift(ticket.number); + responses[i] = answers; + } + + let template = await fsp.readFile(path('./src/commands/extra/survey.template.html'), { + encoding: 'utf8' + }); + + template = template.replace(/\n|\t/, ''); + + survey.questions.unshift('Ticket #'); + + const html = mustache.render(template, { + survey: survey.name.charAt(0).toUpperCase() + survey.name.slice(1), + count: { + responses: count, + users: users.size + }, + columns: survey.questions, + responses + }); + + const attachment = new MessageAttachment( + Buffer.from(html), + `${survey.name}.html` + ); + + return await message.channel.send({ + files: [attachment] + }); + } else { + const surveys = await this.client.db.models.Survey.findAll({ + where: { + guild: message.guild.id + } + }); + + const list = surveys.map(s => `❯ **\`${s.name}\`**`); + return await message.channel.send( + new MessageEmbed() + .setColor(settings.colour) + .setTitle(i18n('commands.survey.response.list.title')) + .setDescription(list.join('\n')) + .setFooter(settings.footer, message.guild.iconURL()) + ); + } + } +}; \ No newline at end of file diff --git a/src/locales/en-GB.json b/src/locales/en-GB.json index d95c7e5..e170f52 100644 --- a/src/locales/en-GB.json +++ b/src/locales/en-GB.json @@ -359,6 +359,25 @@ } } }, + "survey": { + "aliases": { + "surveys": "surveys" + }, + "args": { + "survey": { + "description": "The name of the survey to view responses of", + "example": "support", + "name": "survey" + } + }, + "description": "View survey responses", + "name": "survey", + "response": { + "list": { + "title": "📃 Surveys" + } + } + }, "tag": { "aliases": { "faq": "faq", @@ -460,6 +479,16 @@ "released": { "description": "%s has released this ticket.", "title": "✅ Ticket released" + }, + "survey": { + "complete": { + "description": "Thank you for your feedback.", + "title": "✅ Thank you" + }, + "start": { + "description": "Hey, %s. Before this channel is deleted, would you mind completing a quick %d-question survey? React with ✅ to start, or ignore this message.", + "title": "❔ Feedback" + } } } } \ No newline at end of file diff --git a/src/modules/tickets/manager.js b/src/modules/tickets/manager.js index 18680ec..ca49516 100644 --- a/src/modules/tickets/manager.js +++ b/src/modules/tickets/manager.js @@ -51,16 +51,16 @@ module.exports = class TicketManager extends EventEmitter { })) + 1; const guild = this.client.guilds.cache.get(guild_id); - const member = await guild.members.fetch(creator_id); + const creator = await guild.members.fetch(creator_id); const name = cat_row.name_format - .replace(/{+\s?(user)?name\s?}+/gi, member.displayName) + .replace(/{+\s?(user)?name\s?}+/gi, creator.displayName) .replace(/{+\s?num(ber)?\s?}+/gi, number); const t_channel = await guild.channels.create(name, { type: 'text', - topic: `${member}${topic.length > 0 ? ` | ${topic}` : ''}`, + topic: `${creator}${topic.length > 0 ? ` | ${topic}` : ''}`, parent: category_id, - reason: `${member.user.tag} requested a new ticket channel` + reason: `${creator.user.tag} requested a new ticket channel` }); t_channel.updateOverwrite(creator_id, { @@ -68,7 +68,7 @@ module.exports = class TicketManager extends EventEmitter { READ_MESSAGE_HISTORY: true, SEND_MESSAGES: true, ATTACH_FILES: true - }, `Ticket channel created by ${member.user.tag}`); + }, `Ticket channel created by ${creator.user.tag}`); const t_row = await this.client.db.models.Ticket.create({ id: t_channel.id, @@ -102,17 +102,17 @@ module.exports = class TicketManager extends EventEmitter { } const description = cat_row.opening_message - .replace(/{+\s?(user)?name\s?}+/gi, member.displayName) - .replace(/{+\s?(tag|ping|mention)?\s?}+/gi, member.user.toString()); + .replace(/{+\s?(user)?name\s?}+/gi, creator.displayName) + .replace(/{+\s?(tag|ping|mention)?\s?}+/gi, creator.user.toString()); const embed = new MessageEmbed() .setColor(settings.colour) - .setAuthor(member.user.username, member.user.displayAvatarURL()) + .setAuthor(creator.user.username, creator.user.displayAvatarURL()) .setDescription(description) .setFooter(settings.footer, guild.iconURL()); if (topic) embed.addField(i18n('ticket.opening_message.fields.topic'), topic); - const sent = await t_channel.send(member.user.toString(), embed); + const sent = await t_channel.send(creator.user.toString(), embed); await sent.pin({ reason: 'Ticket opening message' }); await t_row.update({ @@ -158,11 +158,11 @@ module.exports = class TicketManager extends EventEmitter { await t_row.update({ topic: this.client.cryptr.encrypt(topic) }); - await t_channel.setTopic(`${member} | ${topic}`, { reason: 'User updated ticket topic' }); + await t_channel.setTopic(`${creator} | ${topic}`, { reason: 'User updated ticket topic' }); await sent.edit( new MessageEmbed() .setColor(settings.colour) - .setAuthor(member.user.username, member.user.displayAvatarURL()) + .setAuthor(creator.user.username, creator.user.displayAvatarURL()) .setDescription(description) .addField(i18n('ticket.opening_message.fields.topic'), topic) .setFooter(settings.footer, guild.iconURL()) @@ -196,7 +196,7 @@ module.exports = class TicketManager extends EventEmitter { } })(); - this.client.log.info(`${member.user.tag} created a new ticket in "${guild.name}"`); + this.client.log.info(`${creator.user.tag} created a new ticket in "${guild.name}"`); this.emit('create', t_row.id, creator_id); @@ -222,32 +222,38 @@ module.exports = class TicketManager extends EventEmitter { const i18n = this.client.i18n.getLocale(settings.locale); const channel = await this.client.channels.fetch(t_row.id); - if (closer_id) { - const member = await guild.members.fetch(closer_id); + const close = async () => { + const pinned = await channel.messages.fetchPinned(); + await t_row.update({ + open: false, + closed_by: closer_id || null, + closed_reason: reason ? this.client.cryptr.encrypt(reason) : null, + pinned_messages: [...pinned.keys()] + }); - await this.archives.updateMember(ticket_id, member); + if (closer_id) { + const closer = await guild.members.fetch(closer_id); + + await this.archives.updateMember(ticket_id, closer); - if (channel) { const description = reason - ? i18n('ticket.closed_by_member_with_reason.description', member.user.toString(), reason) - : i18n('ticket.closed_by_member.description', member.user.toString()); + ? i18n('ticket.closed_by_member_with_reason.description', closer.user.toString(), reason) + : i18n('ticket.closed_by_member.description', closer.user.toString()); await channel.send( new MessageEmbed() .setColor(settings.success_colour) - .setAuthor(member.user.username, member.user.displayAvatarURL()) + .setAuthor(closer.user.username, closer.user.displayAvatarURL()) .setTitle(i18n('ticket.closed.title')) .setDescription(description) .setFooter(settings.footer, guild.iconURL()) ); setTimeout(async () => { - await channel.delete(`Ticket channel closed by ${member.user.tag}${reason ? `: "${reason}"` : ''}`); + await channel.delete(`Ticket channel closed by ${closer.user.tag}${reason ? `: "${reason}"` : ''}`); }, 5000); - } - this.client.log.info(`${member.user.tag} closed a ticket (${ticket_id})${reason ? `: "${reason}"` : ''}`); - } else { - if (channel) { + this.client.log.info(`${closer.user.tag} closed a ticket (${ticket_id})${reason ? `: "${reason}"` : ''}`); + } else { const description = reason ? i18n('ticket.closed_with_reason.description') : i18n('ticket.closed.description'); @@ -262,20 +268,100 @@ module.exports = class TicketManager extends EventEmitter { setTimeout(async () => { await channel.delete(`Ticket channel closed${reason ? `: "${reason}"` : ''}`); }, 5000); + + this.client.log.info(`A ticket was closed (${ticket_id})${reason ? `: "${reason}"` : ''}`); } + }; - this.client.log.info(`A ticket was closed (${ticket_id})${reason ? `: "${reason}"` : ''}`); + if (channel) { + const creator = await guild.members.fetch(t_row.creator); + + const cat_row = await this.client.db.models.Category.findOne({ + where: { + id: t_row.category + } + }); + + if (creator && cat_row.survey) { + const survey = await this.client.db.models.Survey.findOne({ + where: { + guild: t_row.guild, + name: cat_row.survey + } + }); + + if (survey) { + const r_collector_message = await channel.send( + creator.toString(), + new MessageEmbed() + .setColor(settings.colour) + .setTitle(i18n('ticket.survey.start.title')) + .setDescription(i18n('ticket.survey.start.description', creator.toString(), survey.questions.length)) + .setFooter(i18n('collector_expires_in', 60)) + ); + + await r_collector_message.react('✅'); + + const collector_filter = (reaction, user) => { + return user.id === creator.user.id && reaction.emoji.name === '✅'; + }; + + const r_collector = r_collector_message.createReactionCollector(collector_filter, { + time: 60000 + }); + + r_collector.on('collect', async () => { + r_collector.stop(); + const filter = message => message.author.id === creator.id; + let answers = []; + let number = 1; + for (const question of survey.questions) { + await channel.send( + new MessageEmbed() + .setColor(settings.colour) + .setTitle(`${number++}/${survey.questions.length}`) + .setDescription(question) + .setFooter(i18n('collector_expires_in', 60)) + ); + + try { + const collected = await channel.awaitMessages(filter, { max: 1, time: 60000, errors: ['time'] }); + answers.push(collected.first().content); + } catch (collected) { + return await close(); + } + } + + await channel.send( + new MessageEmbed() + .setColor(settings.success_colour) + .setTitle(i18n('ticket.survey.complete.title')) + .setDescription(i18n('ticket.survey.complete.description')) + .setFooter(settings.footer, guild.iconURL()) + ); + + answers = answers.map(a => this.client.cryptr.encrypt(a)); + await this.client.db.models.SurveyResponse.create({ + answers, + survey: survey.id, + ticket: t_row.id + }); + + await close(); + + }); + + r_collector.on('end', async (collected) => { + if (collected.size === 0) { + await close(); + } + }); + } + } else { + await close(); + } } - const pinned = await channel.messages.fetchPinned(); - - await t_row.update({ - open: false, - closed_by: closer_id || null, - closed_reason: reason ? this.client.cryptr.encrypt(reason) : null, - pinned_messages: [...pinned.keys()] - }); - this.emit('close', ticket_id); return t_row; }