diff --git a/.gitignore b/.gitignore index 4b697a0..db3f820 100644 --- a/.gitignore +++ b/.gitignore @@ -5,11 +5,12 @@ node_modules/ prisma/ # files -.env +*.env* *.db *.db-journal *.log *-lock.* user/config.yml user/**/*.* -!user/**/.gitkeep \ No newline at end of file +!user/**/.gitkeep +!user/templates/* \ No newline at end of file diff --git a/package.json b/package.json index a17c8e8..86575ea 100644 --- a/package.json +++ b/package.json @@ -55,9 +55,11 @@ "leeks.js": "^0.2.4", "leekslazylogger": "^4.1.7", "ms": "^2.1.3", + "mustache": "^4.2.0", "node-dir": "^0.1.17", "node-emoji": "^1.11.0", "object-diffy": "^1.0.4", + "pad": "^3.2.0", "prisma": "^4.5.0", "semver": "^7.3.8", "terminal-link": "^2.1.1", diff --git a/src/autocomplete/references.js b/src/autocomplete/references.js index 87b8d40..f5bd54c 100644 --- a/src/autocomplete/references.js +++ b/src/autocomplete/references.js @@ -11,6 +11,13 @@ module.exports = class ReferencesCompleter extends Autocompleter { }); } + format(ticket) { + const date = new Date(ticket.createdAt).toLocaleString(ticket.guild.locale, { dateStyle: 'short' }); + const topic = ticket.topic ? '| ' + decrypt(ticket.topic).substring(0, 50) : ''; + const category = emoji.hasEmoji(ticket.category.emoji) ? emoji.get(ticket.category.emoji) + ' ' + ticket.category.name : ticket.category.name; + return `${category} #${ticket.number} - ${date} ${topic}`; + } + /** * @param {string} value * @param {*} comamnd @@ -19,7 +26,6 @@ module.exports = class ReferencesCompleter extends Autocompleter { async run(value, comamnd, interaction) { /** @type {import("client")} */ const client = this.client; - const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } }); const tickets = await client.prisma.ticket.findMany({ include: { category: { @@ -28,6 +34,7 @@ module.exports = class ReferencesCompleter extends Autocompleter { name: true, }, }, + guild: true, }, where: { createdById: interaction.user.id, @@ -35,23 +42,14 @@ module.exports = class ReferencesCompleter extends Autocompleter { open: false, }, }); - const options = value ? tickets.filter(t => - String(t.number).match(new RegExp(value, 'i')) || - t.topic?.match(new RegExp(value, 'i')) || - new Date(t.createdAt).toLocaleString(settings.locale, { dateStyle: 'short' })?.match(new RegExp(value, 'i')), - ) : tickets; + const options = value ? tickets.filter(t => this.format(t).match(new RegExp(value, 'i'))) : tickets; await interaction.respond( options .slice(0, 25) - .map(t => { - const date = new Date(t.createdAt).toLocaleString(settings.locale, { dateStyle: 'short' }); - const topic = t.topic ? '| ' + decrypt(t.topic).substring(0, 50) : ''; - const category = emoji.hasEmoji(t.category.emoji) ? emoji.get(t.category.emoji) + ' ' + t.category.name : t.category.name; - return { - name: `${category} #${t.number} - ${date} ${topic}`, - value: t.id, - }; - }), + .map(t => ({ + name: this.format(t), + value: t.id, + })), ); } }; \ No newline at end of file diff --git a/src/autocomplete/ticket.js b/src/autocomplete/ticket.js index f1bbb24..fec52e6 100644 --- a/src/autocomplete/ticket.js +++ b/src/autocomplete/ticket.js @@ -11,6 +11,13 @@ module.exports = class TicketCompleter extends Autocompleter { }); } + format(ticket) { + const date = new Date(ticket.createdAt).toLocaleString(ticket.guild.locale, { dateStyle: 'short' }); + const topic = ticket.topic ? '| ' + decrypt(ticket.topic).substring(0, 50) : ''; + const category = emoji.hasEmoji(ticket.category.emoji) ? emoji.get(ticket.category.emoji) + ' ' + ticket.category.name : ticket.category.name; + return `${category} #${ticket.number} - ${date} ${topic}`; + } + /** * @param {string} value * @param {*} command @@ -19,7 +26,6 @@ module.exports = class TicketCompleter extends Autocompleter { async run(value, command, interaction) { /** @type {import("client")} */ const client = this.client; - const settings = await client.prisma.guild.findUnique({ where: { id: interaction.guild.id } }); const tickets = await client.prisma.ticket.findMany({ include: { category: { @@ -28,6 +34,7 @@ module.exports = class TicketCompleter extends Autocompleter { name: true, }, }, + guild: true, }, where: { createdById: interaction.user.id, @@ -35,23 +42,14 @@ module.exports = class TicketCompleter extends Autocompleter { open: ['add', 'close', 'force-close', 'remove'].includes(command.name), // false for `new`, `transcript` etc }, }); - const options = value ? tickets.filter(t => - String(t.number).match(new RegExp(value, 'i')) || - t.topic?.match(new RegExp(value, 'i')) || - new Date(t.createdAt).toLocaleString(settings.locale, { dateStyle: 'short' })?.match(new RegExp(value, 'i')), - ) : tickets; + const options = value ? tickets.filter(t => this.format(t).match(new RegExp(value, 'i'))) : tickets; await interaction.respond( options .slice(0, 25) - .map(t => { - const date = new Date(t.createdAt).toLocaleString(settings.locale, { dateStyle: 'short' }); - const topic = t.topic ? '| ' + decrypt(t.topic).substring(0, 50) : ''; - const category = emoji.hasEmoji(t.category.emoji) ? emoji.get(t.category.emoji) + ' ' + t.category.name : t.category.name; - return { - name: `${category} #${t.number} - ${date} ${topic}`, - value: t.id, - }; - }), + .map(t => ({ + name: this.format(t), + value: t.id, + })), ); } }; \ No newline at end of file diff --git a/src/commands/slash/transcript.js b/src/commands/slash/transcript.js index 3b6cc54..890f474 100644 --- a/src/commands/slash/transcript.js +++ b/src/commands/slash/transcript.js @@ -1,5 +1,12 @@ const { SlashCommand } = require('@eartharoid/dbf'); const { ApplicationCommandOptionType } = require('discord.js'); +const fs = require('fs'); +const { join } = require('path'); +const Mustache = require('mustache'); +const { AttachmentBuilder } = require('discord.js'); +const Cryptr = require('cryptr'); +const { decrypt } = new Cryptr(process.env.ENCRYPTION_KEY); +const pad = require('pad'); module.exports = class TranscriptSlashCommand extends SlashCommand { constructor(client, options) { @@ -41,7 +48,116 @@ module.exports = class TranscriptSlashCommand extends SlashCommand { nameLocalizations, options: opts, }); + + Mustache.escape = text => text; // don't HTML-escape + this.template = fs.readFileSync( + join('./user/templates/', this.client.config.templates.transcript), + { encoding: 'utf8' }, + ); } - async run(interaction) { } + async fillTemplate(ticketId) { + /** @type {import("client")} */ + const client = this.client; + const ticket = await client.prisma.ticket.findUnique({ + include: { + archivedChannels: true, + archivedMessages: { + orderBy: { createdAt: 'asc' }, + where: { external: false }, + }, + archivedRoles: true, + archivedUsers: true, + category: true, + claimedBy: true, + closedBy: true, + createdBy: true, + feedback: true, + guild: true, + questionAnswers: true, + }, + where: { id: ticketId }, + }); + if (!ticket) throw new Error(`Ticket ${ticketId} does not exist`); + + ticket.claimedBy = ticket.archivedUsers.find(u => u.userId === ticket.claimedById); + ticket.closedBy = ticket.archivedUsers.find(u => u.userId === ticket.closedById); + ticket.createdBy = ticket.archivedUsers.find(u => u.userId === ticket.createdById); + + if (ticket.closedReason) ticket.closedReason = decrypt(ticket.closedReason); + if (ticket.feedback?.comment) ticket.feedback.comment = decrypt(ticket.feedback.comment); + if (ticket.topic) ticket.topic = decrypt(ticket.topic); + + ticket.archivedUsers.forEach((user, i) => { + if (user.displayName) user.displayName = decrypt(user.displayName); + user.username = decrypt(user.username); + ticket.archivedUsers[i] = user; + }); + + ticket.archivedMessages.forEach((message, i) => { + message.author = ticket.archivedUsers.find(u => u.userId === message.authorId); + message.content = JSON.parse(decrypt(message.content)); + message.text = message.content.content?.replace(/\n/g, '\n\t') ?? ''; + message.content.attachments?.forEach(a => (message.text += '\n\t' + a.url)); + message.content.embeds?.forEach(() => (message.text += '\n\t[embedded content]')); + message.number = 'M' + pad(String(ticket.archivedMessages.length).length, i + 1, '0'); + ticket.archivedMessages[i] = message; + }); + + ticket.pinnedMessageIds = ticket.pinnedMessageIds.map(id => ticket.archivedMessages.find(message => message.id === id)?.number); + + const channelName = ticket.category.channelName + .replace(/{+\s?(user)?name\s?}+/gi, ticket.createdBy?.username) + .replace(/{+\s?(nick|display)(name)?\s?}+/gi, ticket.createdBy?.displayName) + .replace(/{+\s?num(ber)?\s?}+/gi, ticket.number); + const fileName = `${channelName}.${this.client.config.templates.transcript.split('.').slice(-1)[0]}`; + const transcript = Mustache.render(this.template, { + channelName, + closedAtFull: function () { + return new Intl.DateTimeFormat([ticket.guild.locale, 'en-GB'], { + dateStyle: 'full', + timeStyle: 'long', + timeZone: 'Etc/UTC', + }).format(this.closedAt); + }, + createdAtFull: function () { + return new Intl.DateTimeFormat([ticket.guild.locale, 'en-GB'], { + dateStyle: 'full', + timeStyle: 'long', + timeZone: 'Etc/UTC', + }).format(this.createdAt); + }, + createdAtTimestamp: function () { + return new Intl.DateTimeFormat([ticket.guild.locale, 'en-GB'], { + dateStyle: 'short', + timeStyle: 'long', + timeZone: 'Etc/UTC', + }).format(this.createdAt); + }, + guildName: client.guilds.cache.get(ticket.guildId)?.name, + pinned: ticket.pinnedMessageIds.join(', '), + ticket, + }); + + return { + fileName, + transcript, + }; + } + + /** + * @param {import("discord.js").ChatInputCommandInteraction} interaction + */ + async run(interaction) { + await interaction.deferReply({ ephemeral: true }); + const { + fileName, + transcript, + } = await this.fillTemplate(interaction.options.getString('ticket', true)); + const attachment = new AttachmentBuilder() + .setFile(Buffer.from(transcript)) + .setName(fileName); + await interaction.editReply({ files: [attachment] }); + // TODO: add portal link + } }; \ No newline at end of file diff --git a/user/templates/transcript.md b/user/templates/transcript.md new file mode 100644 index 0000000..ad6bef9 --- /dev/null +++ b/user/templates/transcript.md @@ -0,0 +1,26 @@ +#{{ channelName }} ticket transcript +--- +ID: {{ ticket.id }} +Number: {{ guildName }} #{{ ticket.number }} +Topic: {{ #ticket.topic }}{{ . }}{{ /ticket.topic }} +Created on: {{ #ticket }}{{ createdAtFull }}{{ /ticket }} +Created by: {{ #ticket.createdBy }}"{{ displayName }}" @{{ username }}#{{ discriminator }}{{ /ticket.createdBy }} +Closed on: {{ #ticket }}{{ closedAtFull }}{{ /ticket }} +Closed by: {{ #ticket.closedBy }}"{{ displayName }}" @{{ username }}#{{ discriminator }}{{ /ticket.closedBy }}{{ ^ticket.closedBy }}(automated){{ /ticket.closedBy }} +Closed because: {{ #ticket.closedReason }}{{ ticket.closedReason }}{{ /ticket.closedReason }}{{ ^ticket.closedReason }}(no reason){{ /ticket.closedReason }} +Claimed by: {{ #ticket.claimedBy }}"{{ displayName }}" @{{ username }}#{{ discriminator }}{{ /ticket.claimedBy }}{{ ^ticket.claimedBy }}(not claimed){{ /ticket.claimedBy }} +{{ #ticket.feedback }} +Feedback: + Rating: {{ rating }}/5 + Comment: {{ comment }}{{ ^comment }}(no comment){{ /comment }} +{{ /ticket.feedback }} +Participants: +{{ #ticket.archivedUsers }} + - "{{ displayName }}" @{{ username }}#{{ discriminator }} ({{ userId }}) +{{ /ticket.archivedUsers }} +Pinned messages: {{ #pinned }}{{ . }}{{ /pinned }} +--- + +{{ #ticket.archivedMessages }} +<{{ number }}> [{{ createdAtTimestamp }}] {{author.displayName}}: {{ text }} +{{ /ticket.archivedMessages }} \ No newline at end of file