diff --git a/structure/Client.js b/structure/Client.js index 8b5d3a9..896cbed 100644 --- a/structure/Client.js +++ b/structure/Client.js @@ -131,6 +131,21 @@ class ModmailClient extends Client { return this.resolver.resolveUser(input); } + async prompt(str, { author, channel, time }) { + + if (!channel && author) channel = await author.createDM(); + if (!channel) throw new Error(`Missing channel for prompt, must pass at least author.`); + await channel.send(str); + return channel.awaitMessages((m) => m.author.id === author.id, { max: 1, time: time || 30000, errors: ['time'] }) + .then((collected) => { + return collected.first(); + }) + .catch((error) => { //eslint-disable-line no-unused-vars, handle-callback-err + return null; + }); + + } + } module.exports = ModmailClient; \ No newline at end of file diff --git a/structure/Modmail.js b/structure/Modmail.js index 0cc6024..47a690f 100644 --- a/structure/Modmail.js +++ b/structure/Modmail.js @@ -34,6 +34,7 @@ class Modmail { // Sweep graveyard every 30 min and move stale channels to graveyard this.sweeper = setInterval(this.sweepChannels.bind(this), 5 * 60 * 1000); + this.saver = setInterval(this.saveHistory.bind(this), 30 * 1000); let logStr = `Started modmail handler for ${this.mainServer.name}`; if (this.bansServer) logStr += ` with ${this.bansServer.name} for ban appeals`; @@ -125,8 +126,7 @@ class Modmail { if (!cache._channels) cache._channels = {}; cache._channels[author.id] = channel; - this.mmcache[author.id] = pastModmail; - this.mmcache[author.id].push({ author: author.id, content, timestamp: Date.now(), isReply: false }); + pastModmail.push({ author: author.id, content, timestamp: Date.now(), isReply: false }); if (!this.updatedThreads.includes(author.id)) this.updatedThreads.push(author.id); const embed = { @@ -154,8 +154,6 @@ class Modmail { this.client.logger.error(`channel.send errored:\n${err.stack}\nContent: "${content}"`); }); - if(!this.timeout || this.timeout._destroyed) this.timeout = setTimeout(this.saveHistory.bind(this), 30 * 1000); - } /** @@ -217,6 +215,10 @@ class Modmail { for (let i = context < len ? context : len; i > 0; i--) { const entry = history[len - i]; if (!entry) continue; + if (entry.markread) { + i++; + continue; + } const mem = entry.author.id === member.id ? member : this.mainServer.members.resolve(entry.author); @@ -350,12 +352,11 @@ class Modmail { this.client.logger.error(`Error during channel transition:\n${err.stack}`); }); await message.delete().catch(this.client.logger.warn.bind(this.client.logger)); + if (!this.updatedThreads.includes(author.id)) this.updatedThreads.push(author.id); } async sendModmail({ message, content, anon, target }) { - - console.log(content, anon, target.tag); const targetMember = await this.getMember(target.id); if (!targetMember) return { @@ -363,12 +364,12 @@ class Modmail { msg: `Cannot find member` }; - const pastModmail = await this.loadHistory(target.id) + const history = await this.loadHistory(target.id) .catch((err) => { this.client.logger.error(`Error during loading of past mail:\n${err.stack}`); return { error: true }; }); - if (pastModmail.error) return { + if (history.error) return { error: true, msg: `Internal error, this has been logged.` }; @@ -395,7 +396,9 @@ class Modmail { if (sent.error) return sent; await message.channel.send('Delivered.').catch(this.client.logger.error.bind(this.client.logger)); - const channel = await this.loadChannel(targetMember, pastModmail).catch(this.client.logger.error.bind(this.client.logger)); + const channel = await this.loadChannel(targetMember, history).catch(this.client.logger.error.bind(this.client.logger)); + history.push({ author: member.id, content, timestamp: Date.now(), isReply: true, anon }); + if (!this.updatedThreads.includes(author.id)) this.updatedThreads.push(author.id); await channel.send({ embed }).catch(this.client.logger.error.bind(this.client.logger)); await channel.edit({ parentID: this.readMail.id }).catch(this.client.logger.error.bind(this.client.logger)); @@ -465,14 +468,37 @@ class Modmail { async markread(message) { - const { channel } = message; + const { channel, author } = message; if (!this.categories.includes(channel.parentID)) return { error: true, msg: `This command only works in modmail channels.` }; + const chCache = this.client.cache.channels; + const result = Object.entries(chCache).find(([, val]) => { + return val === channel.id; + }); + + if (!result) return { + error: true, + msg: `This doesn't seem to be a valid modmail channel. Cache might be out of sync. **[MISSING TARGET]**` + }; + + const [userId] = result; + const history = await this.loadHistory(userId) + .catch((err) => { + this.client.logger.error(`Error during loading of past mail:\n${err.stack}`); + return { error: true }; + }); + if (history.error) return { + error: true, + msg: `Internal error, this has been logged.` + }; + history.push({ author: author.id, timestamp: Date.now(), markread: true }); // To keep track of read state + await channel.edit({ parentID: this.readMail.id }); + if (!this.updatedThreads.includes(author.id)) this.updatedThreads.push(userId); return `Done`; } @@ -488,7 +514,9 @@ class Modmail { fs.readFile(path, { encoding: 'utf-8' }, (err, data) => { if (err) reject(err); - resolve(JSON.parse(data)); + const parsed = JSON.parse(data); + this.mmcache[userId] = parsed; + resolve(parsed); }); }); @@ -515,11 +543,19 @@ class Modmail { loadReplies() { + this.client.logger.info('Loading canned replies'); if (!fs.existsSync('./canned_replies.json')) return {}; return JSON.parse(fs.readFileSync('./canned_replies.json', { encoding: 'utf-8' })); } + saveReplies() { + + this.client.logger.info('Saving canned replies'); + fs.writeFileSync('./canned_replies.json', JSON.stringify(this.replies)); + + } + } module.exports = Modmail; \ No newline at end of file diff --git a/structure/commands/CannedReply.js b/structure/commands/CannedReply.js index efb4a50..df60e5f 100644 --- a/structure/commands/CannedReply.js +++ b/structure/commands/CannedReply.js @@ -15,17 +15,69 @@ class CannedReply extends Command { const [first] = args.map((a) => a); // eslint-disable-next-line prefer-const - let { content, _caller } = message, + let { channel, content, _caller } = message, anon = false; content = content.replace(`${this.client.prefix}${_caller}`, ''); - if (first.toLowerCase() === 'anon') { + const op = args.shift().toLowerCase(); + if (op === 'anon') { anon = true; content = content.replace(first, ''); + } else if (['create', 'delete'].includes(op)) { + return this.createCanned(op, args, message); + } else if (['list'].includes(first.toLowerCase(op))) { + + const list = Object.entries(this.client.modmail.replies); + let str = ''; + for (const [name, content] of list) { + if (str.length + content.length > 2000) { + await channel.send(str).catch(this.client.logger.error.bind(this.client.logger)); + str = ''; + } + str += `**${name}:** ${content}\n`; + } + if (str.length) await channel.send(str).catch(this.client.logger.error.bind(this.client.logger)); + return; } return this.client.modmail.sendCannedResponse({ message, responseName: content.trim(), anon }); } + async createCanned(op, args, { channel, author }) { + + if (args.length < 1) return { + error: true, + msg: 'Missing reply name' + }; + const [_name, ...rest] = args; + + const name = _name.toLowerCase(); + const canned = this.client.modmail.replies; + let confirmation = null; + + if (op === 'create') { + if (!rest.length) return { + error: true, + msg: 'Missing content' + }; + + if (canned[name]) { + confirmation = await this.client.prompt(`A canned reply by the name ${name} already exists, would you like to overwrite it?`, { channel, author }); + if (!confirmation) return 'Timed out.'; + confirmation = ['y', 'yes', 'ok'].includes(confirmation.content.toLowerCase()); + if (!confirmation) return 'Cancelled'; + } + + canned[name] = rest.join(' '); + + } else { + delete canned[name]; + } + + this.client.modmail.saveReplies(); + return `Updated ${_name}`; + + } + } module.exports = CannedReply; \ No newline at end of file diff --git a/structure/commands/Logs.js b/structure/commands/Logs.js new file mode 100644 index 0000000..9ef6a0a --- /dev/null +++ b/structure/commands/Logs.js @@ -0,0 +1,71 @@ +const Command = require('../Command'); + +class Logs extends Command { + + constructor(client) { + super(client, { + name: 'logs', + aliases: ['mmlogs', 'mmhistory'], + showUsage: true, + usage: ' [page]' + }); + } + + async execute(message, args) { + + const user = await this.client.resolveUser(args[0]); + let pageNr = 1; + if (args[1]) { + const num = parseInt(args[1]); + if (isNaN(num)) return { + error: true, + msg: 'Invalid page number, must be number' + }; + pageNr = num; + } + + const { member, channel } = message; + const history = await this.client.modmail.loadHistory(user.id); + const page = this.paginate([...history].reverse(), pageNr, 10); + + const embed = { + author: { + name: `${user.tag} modmail history`, + // eslint-disable-next-line camelcase + icon_url: user.displayAvatarURL({ dynamic: true }) + }, + footer: { + text: `${user.id} | Page ${page.page}/${page.maxPage}` + }, + fields: [], + color: member.highestRoleColor + }; + + for (const entry of page.items) { + const user = await this.client.resolveUser(entry.author); + embed.fields.push({ + name: `${user.tag}${entry.anon ? ' (ANON)' : ''} @ ${new Date(entry.timestamp).toUTCString()}`, + value: entry.content.substring(0, 1000) + (entry.content.length > 1000 ? '...' : '') + }); + } + + await channel.send({ embed }); + + } + + paginate(items, page = 1, pageLength = 10) { + const maxPage = Math.ceil(items.length / pageLength); + if (page < 1) page = 1; + if (page > maxPage) page = maxPage; + const startIndex = (page - 1) * pageLength; + return { + items: items.length > pageLength ? items.slice(startIndex, startIndex + pageLength) : items, + page, + maxPage, + pageLength + }; + } + +} + +module.exports = Logs; \ No newline at end of file