Survey responses!

and some small changes/fixes
This commit is contained in:
Isaac 2021-05-19 15:24:02 +01:00
parent 053fcdb4b8
commit 20ac8bff73
No known key found for this signature in database
GPG Key ID: F6812DBC6719B4E3
9 changed files with 340 additions and 40 deletions

View File

@ -38,6 +38,7 @@
"keyv": "^4.0.3", "keyv": "^4.0.3",
"leeks.js": "^0.2.2", "leeks.js": "^0.2.2",
"leekslazylogger-fastify": "^0.1.1", "leekslazylogger-fastify": "^0.1.1",
"mustache": "^4.2.0",
"node-emoji": "^1.10.0", "node-emoji": "^1.10.0",
"node-fetch": "^2.6.1", "node-fetch": "^2.6.1",
"semver": "^7.3.4", "semver": "^7.3.4",

View File

@ -13,6 +13,7 @@ specifiers:
leeks.js: ^0.2.2 leeks.js: ^0.2.2
leekslazylogger-fastify: ^0.1.1 leekslazylogger-fastify: ^0.1.1
mariadb: ^2.5.2 mariadb: ^2.5.2
mustache: ^4.2.0
mysql2: ^2.2.5 mysql2: ^2.2.5
node-emoji: ^1.10.0 node-emoji: ^1.10.0
node-fetch: ^2.6.1 node-fetch: ^2.6.1
@ -38,6 +39,7 @@ dependencies:
keyv: 4.0.3 keyv: 4.0.3
leeks.js: 0.2.2 leeks.js: 0.2.2
leekslazylogger-fastify: 0.1.1 leekslazylogger-fastify: 0.1.1
mustache: 4.2.0
node-emoji: 1.10.0 node-emoji: 1.10.0
node-fetch: 2.6.1 node-fetch: 2.6.1
semver: 7.3.4 semver: 7.3.4
@ -1625,6 +1627,11 @@ packages:
/ms/2.1.3: /ms/2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 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: /mysql2/2.2.5:
resolution: {integrity: sha512-XRqPNxcZTpmFdXbJqb+/CtYVLCx14x1RTeNMD4954L331APu75IC74GDqnZMEt1kwaXy6TySo55rF2F3YJS78g==} resolution: {integrity: sha512-XRqPNxcZTpmFdXbJqb+/CtYVLCx14x1RTeNMD4954L331APu75IC74GDqnZMEt1kwaXy6TySo55rF2F3YJS78g==}
engines: {node: '>= 8.0'} engines: {node: '>= 8.0'}

View File

@ -82,7 +82,7 @@ module.exports = class BlacklistCommand extends Command {
const member_or_role = is_role ? 'role' : 'member'; const member_or_role = is_role ? 'role' : 'member';
const index = settings.blacklist.findIndex(element => element === id); const index = settings.blacklist.findIndex(element => element === id);
const new_blacklist = [ ...settings.blacklist ]; const new_blacklist = [...settings.blacklist];
if (index === -1) { if (index === -1) {
new_blacklist.push(id); new_blacklist.push(id);

View File

@ -66,7 +66,6 @@
}, },
"required": [ "required": [
"name", "name",
"name_format",
"opening_message", "opening_message",
"roles" "roles"
] ]

View File

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html>
<head>
<title>{{survey}} Survey Responses | Discord Tickets</title>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1, user-scalable=no'>
<meta http-equiv='X-UA-Compatible' content='IE=edge'>
<link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css'>
<link rel='stylesheet' href='https://jenil.github.io/bulmaswatch/darkly/bulmaswatch.min.css'>
</head>
<body>
<section class='section'>
<container class='container box has-text-centered'>
<div class='content'>
<h1>{{survey}} survey responses</h1>
</div>
<div class='level'>
<div class='level-item has-text-centered'>
<div class='box'>
<p class='title'>{{count.responses}}</p>
<p class='heading'>Responses</p>
</div>
</div>
<div class='level-item has-text-centered'>
<div class='box'>
<p class='title'>{{count.users}}</p>
<p class='heading'>Users</p>
</div>
</div>
</div>
<div class='table-container'>
<table class='table is-bordered is-striped is-hoverable is-fullwidth'>
<thead>
<tr>
{{#columns}}
<th>{{.}}</th>
{{/columns}}
</tr>
</thead>
<tbody>
{{#responses}}
<tr>
{{#.}}
<td>{{.}}</td>
{{/.}}
</tr>
{{/responses}}
</tbody>
<tfoot>
<tr>
{{#columns}}
<th>{{.}}</th>
{{/columns}}
</tr>
</tfoot>
</table>
</div>
</container>
</section>
</body>
</html>

View File

@ -19,8 +19,7 @@ module.exports = class SettingsCommand extends Command {
permissions: ['MANAGE_GUILD'] permissions: ['MANAGE_GUILD']
}); });
this.schema = require('../settings.schema.json'); this.schema = require('./extra/settings.schema.json');
this.v = new Validator(); this.v = new Validator();
} }
@ -33,7 +32,7 @@ module.exports = class SettingsCommand extends Command {
const settings = await message.guild.settings; const settings = await message.guild.settings;
const i18n = this.client.i18n.getLocale(settings.locale); const i18n = this.client.i18n.getLocale(settings.locale);
const attachments = [ ...message.attachments.values() ]; const attachments = [...message.attachments.values()];
if (attachments.length >= 1) { if (attachments.length >= 1) {
@ -207,7 +206,7 @@ module.exports = class SettingsCommand extends Command {
`Settings for ${message.guild.name}.json` `Settings for ${message.guild.name}.json`
); );
message.channel.send({ return await message.channel.send({
files: [attachment] files: [attachment]
}); });
} }

112
src/commands/survey.js Normal file
View File

@ -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<void|any>}
*/
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())
);
}
}
};

View File

@ -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": { "tag": {
"aliases": { "aliases": {
"faq": "faq", "faq": "faq",
@ -460,6 +479,16 @@
"released": { "released": {
"description": "%s has released this ticket.", "description": "%s has released this ticket.",
"title": "✅ Ticket released" "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"
}
} }
} }
} }

View File

@ -51,16 +51,16 @@ module.exports = class TicketManager extends EventEmitter {
})) + 1; })) + 1;
const guild = this.client.guilds.cache.get(guild_id); 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 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); .replace(/{+\s?num(ber)?\s?}+/gi, number);
const t_channel = await guild.channels.create(name, { const t_channel = await guild.channels.create(name, {
type: 'text', type: 'text',
topic: `${member}${topic.length > 0 ? ` | ${topic}` : ''}`, topic: `${creator}${topic.length > 0 ? ` | ${topic}` : ''}`,
parent: category_id, 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, { t_channel.updateOverwrite(creator_id, {
@ -68,7 +68,7 @@ module.exports = class TicketManager extends EventEmitter {
READ_MESSAGE_HISTORY: true, READ_MESSAGE_HISTORY: true,
SEND_MESSAGES: true, SEND_MESSAGES: true,
ATTACH_FILES: 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({ const t_row = await this.client.db.models.Ticket.create({
id: t_channel.id, id: t_channel.id,
@ -102,17 +102,17 @@ module.exports = class TicketManager extends EventEmitter {
} }
const description = cat_row.opening_message const description = cat_row.opening_message
.replace(/{+\s?(user)?name\s?}+/gi, member.displayName) .replace(/{+\s?(user)?name\s?}+/gi, creator.displayName)
.replace(/{+\s?(tag|ping|mention)?\s?}+/gi, member.user.toString()); .replace(/{+\s?(tag|ping|mention)?\s?}+/gi, creator.user.toString());
const embed = new MessageEmbed() const embed = new MessageEmbed()
.setColor(settings.colour) .setColor(settings.colour)
.setAuthor(member.user.username, member.user.displayAvatarURL()) .setAuthor(creator.user.username, creator.user.displayAvatarURL())
.setDescription(description) .setDescription(description)
.setFooter(settings.footer, guild.iconURL()); .setFooter(settings.footer, guild.iconURL());
if (topic) embed.addField(i18n('ticket.opening_message.fields.topic'), topic); 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 sent.pin({ reason: 'Ticket opening message' });
await t_row.update({ await t_row.update({
@ -158,11 +158,11 @@ module.exports = class TicketManager extends EventEmitter {
await t_row.update({ await t_row.update({
topic: this.client.cryptr.encrypt(topic) 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( await sent.edit(
new MessageEmbed() new MessageEmbed()
.setColor(settings.colour) .setColor(settings.colour)
.setAuthor(member.user.username, member.user.displayAvatarURL()) .setAuthor(creator.user.username, creator.user.displayAvatarURL())
.setDescription(description) .setDescription(description)
.addField(i18n('ticket.opening_message.fields.topic'), topic) .addField(i18n('ticket.opening_message.fields.topic'), topic)
.setFooter(settings.footer, guild.iconURL()) .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); 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 i18n = this.client.i18n.getLocale(settings.locale);
const channel = await this.client.channels.fetch(t_row.id); const channel = await this.client.channels.fetch(t_row.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()]
});
if (closer_id) { if (closer_id) {
const member = await guild.members.fetch(closer_id); const closer = await guild.members.fetch(closer_id);
await this.archives.updateMember(ticket_id, member); await this.archives.updateMember(ticket_id, closer);
if (channel) {
const description = reason const description = reason
? i18n('ticket.closed_by_member_with_reason.description', member.user.toString(), reason) ? i18n('ticket.closed_by_member_with_reason.description', closer.user.toString(), reason)
: i18n('ticket.closed_by_member.description', member.user.toString()); : i18n('ticket.closed_by_member.description', closer.user.toString());
await channel.send( await channel.send(
new MessageEmbed() new MessageEmbed()
.setColor(settings.success_colour) .setColor(settings.success_colour)
.setAuthor(member.user.username, member.user.displayAvatarURL()) .setAuthor(closer.user.username, closer.user.displayAvatarURL())
.setTitle(i18n('ticket.closed.title')) .setTitle(i18n('ticket.closed.title'))
.setDescription(description) .setDescription(description)
.setFooter(settings.footer, guild.iconURL()) .setFooter(settings.footer, guild.iconURL())
); );
setTimeout(async () => { 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); }, 5000);
}
this.client.log.info(`${member.user.tag} closed a ticket (${ticket_id})${reason ? `: "${reason}"` : ''}`); this.client.log.info(`${closer.user.tag} closed a ticket (${ticket_id})${reason ? `: "${reason}"` : ''}`);
} else { } else {
if (channel) {
const description = reason const description = reason
? i18n('ticket.closed_with_reason.description') ? i18n('ticket.closed_with_reason.description')
: i18n('ticket.closed.description'); : i18n('ticket.closed.description');
@ -262,20 +268,100 @@ module.exports = class TicketManager extends EventEmitter {
setTimeout(async () => { setTimeout(async () => {
await channel.delete(`Ticket channel closed${reason ? `: "${reason}"` : ''}`); await channel.delete(`Ticket channel closed${reason ? `: "${reason}"` : ''}`);
}, 5000); }, 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}"` : ''}`);
} }
};
const pinned = await channel.messages.fetchPinned(); if (channel) {
const creator = await guild.members.fetch(t_row.creator);
await t_row.update({ const cat_row = await this.client.db.models.Category.findOne({
open: false, where: {
closed_by: closer_id || null, id: t_row.category
closed_reason: reason ? this.client.cryptr.encrypt(reason) : null, }
pinned_messages: [...pinned.keys()]
}); });
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();
}
}
this.emit('close', ticket_id); this.emit('close', ticket_id);
return t_row; return t_row;
} }