diff --git a/structure/Client.js b/structure/Client.js index 3ffa9f8..b5d331e 100644 --- a/structure/Client.js +++ b/structure/Client.js @@ -1,5 +1,4 @@ const { Client } = require('discord.js'); -const fs = require('fs'); // eslint-disable-next-line no-unused-vars const { TextChannel, GuildMember } = require('./extensions'); @@ -7,6 +6,7 @@ const { Logger } = require('../logger'); const Modmail = require('./Modmail'); const Registry = require('./Registry'); const Resolver = require('./Resolver'); +const Cache = require('./Cache'); class ModmailClient extends Client { @@ -20,16 +20,15 @@ class ModmailClient extends Client { this.prefix = options.prefix; this.logger = new Logger(this, options.loggerOptions); - this.modmail = new Modmail(this); this.registry = new Registry(this); this.resolver = new Resolver(this); + this.cache = new Cache(this); + this.modmail = new Modmail(this); this.on('ready', () => { this.logger.info(`Client ready, logged in as ${this.user.tag}`); }); - this.cache = null; - } async init() { @@ -38,14 +37,7 @@ class ModmailClient extends Client { this.on('message', this.handleMessage.bind(this)); - if (fs.existsSync('./persistent_cache.json')) { - this.logger.info('Loading cache'); - this.cache = JSON.parse(fs.readFileSync('./persistent_cache.json', { encoding: 'utf-8' })); - } else { - this.logger.info('Cache file missing, creating...'); - this.cache = {}; - this.saveCache(); - } + this.cache.load(); this.logger.info(`Logging in`); await this.login(this._options.discordToken); @@ -58,27 +50,19 @@ class ModmailClient extends Client { process.on('exit', () => { this.logger.warn('process exiting'); - this.saveCache(); - this.modmail.saveHistory(); + this.cache.save(); + this.cache.saveModmailHistory(this.modmail); }); process.on('SIGINT', () => { this.logger.warn('received sigint'); - this.saveCache.bind(this); - this.modmail.saveHistory(); + this.cache.save(); + this.cache.saveModmailHistory(this.modmail); // eslint-disable-next-line no-process-exit process.exit(); }); this._ready = true; - this.cacheSaver = setInterval(this.saveCache.bind(this), 10 * 60 * 1000); - - } - - saveCache() { - this.logger.debug('Saving cache'); - delete this.cache._channels; - fs.writeFileSync('./persistent_cache.json', JSON.stringify(this.cache)); } ready() { @@ -112,7 +96,7 @@ class ModmailClient extends Client { if(!roles.some((r) => this._options.staffRoles.includes(r)) && !member.hasPermission('ADMINISTRATOR')) return; const [rawCommand, ...args] = content.split(' '); - const commandName = rawCommand.substring(prefix.length).toLowerCase(); + const commandName = rawCommand.substring(prefix.length); const command = this.registry.find(commandName); if (!command) return; message._caller = commandName; @@ -142,12 +126,20 @@ class ModmailClient extends Client { } - resolveUser(input) { - return this.resolver.resolveUser(input); + resolveUser(...args) { + return this.resolver.resolveUser(...args); } - resolveUsers(input) { - return this.resolver.resolveUsers(input); + resolveUsers(...args) { + return this.resolver.resolveUsers(...args); + } + + resolveChannels(...args) { + return this.resolver.resolveChannels(...args); + } + + resolveChannel(...args) { + return this.resolver.resolveChannel(...args); } async prompt(str, { author, channel, time }) { diff --git a/structure/Modmail.js b/structure/Modmail.js index f65e4b8..e433998 100644 --- a/structure/Modmail.js +++ b/structure/Modmail.js @@ -1,4 +1,5 @@ const fs = require('fs'); +const ChannelHandler = require('./ChannelHandler'); class Modmail { @@ -8,30 +9,28 @@ class Modmail { constructor(client) { this.client = client; + this.cache = client.cache; this.mainServer = null; this.bansServer = null; const opts = client._options; - this.categories = opts.modmailCategory; - this.graveyardInactive = opts.graveyardInactive; - this.readInactive = opts.readInactive; - this.channelSweepInterval = opts.channelSweepInterval; - this.saveInterval = opts.saveInterval; this.anonColor = opts.anonColor; this.reminderInterval = opts.modmailReminderInterval || 30; this._reminderChannel = opts.modmailReminderChannel || null; this.reminderChannel = null; + this.categories = opts.modmailCategory; this.updatedThreads = []; this.queue = []; - this.mmcache = {}; this.spammers = {}; this.replies = {}; - this.awaitingChannel = {}; this.lastReminder = null; + this.channels = new ChannelHandler(this, opts); + this._ready = false; + } init() { @@ -43,30 +42,25 @@ class Modmail { if (!this.bansServer) this.client.logger.warn(`Missing bans server`); if (!this.anonColor) this.anonColor = this.mainServer.me.highestRoleColor; - - const { channels } = this.mainServer; - this.newMail = channels.resolve(this.categories[0]); - this.readMail = channels.resolve(this.categories[1]); - this.graveyard = channels.resolve(this.categories[2]); this.replies = this.loadReplies(); - this.queue = this.client.cache.queue || []; + this.queue = this.client.cache.queue; + if (this._reminderChannel) { this.reminderChannel = this.client.channels.resolve(this._reminderChannel); this.reminder = setInterval(this.sendReminder.bind(this), this.reminderInterval * 60 * 1000); this.sendReminder(); } - // Sweep graveyard every 30 min and move stale channels to graveyard - this.sweeper = setInterval(this.sweepChannels.bind(this), this.channelSweepInterval * 60 * 1000); - this.saver = setInterval(this.saveHistory.bind(this), this.saveInterval * 60 * 1000); - let logStr = `Started modmail handler for ${this.mainServer.name}`; if (this.bansServer) logStr += ` with ${this.bansServer.name} for ban appeals`; this.client.logger.info(logStr); //this.client.logger.info(`Fetching messages from discord for modmail`); // TODO: Fetch messages from discord in modmail channels + this.channels.init(); + this._ready = true; + } async getMember(user) { @@ -105,7 +99,6 @@ class Modmail { if (!member) return; // No member object found in main or bans server? const now = Math.floor(Date.now() / 1000); - if (!this.client.cache.lastActivity) this.client.cache.lastActivity = {}; const lastActivity = this.client.cache.lastActivity[author.id]; //console.log(now - lastActivity, lastActivity, now) if (!lastActivity || now - lastActivity > 30 * 60) { @@ -114,7 +107,6 @@ class Modmail { this.client.cache.lastActivity[author.id] = now; const { cache } = this.client; - if (!cache.channels) cache.channels = {}; // Anti spam if (!this.spammers[author.id]) this.spammers[author.id] = { start: now, count: 1, timeout: false, warned: false }; @@ -135,14 +127,14 @@ class Modmail { } } - const pastModmail = await this.loadHistory(author.id) + const pastModmail = await this.cache.loadModmailHistory(author.id) .catch((err) => { this.client.logger.error(`Error during loading of past mail:\n${err.stack}`); return { error: true }; }); if (pastModmail.error) return author.send(`Internal error, this has been logged.`); - const channel = await this.loadChannel(member, pastModmail) + const channel = await this.channels.load(member, pastModmail) .catch((err) => { this.client.logger.error(`Error during channel handling:\n${err.stack}`); return { error: true }; @@ -177,7 +169,7 @@ class Modmail { pastModmail.push({ attachments, author: author.id, content, timestamp: Date.now(), isReply: false }); if (!this.updatedThreads.includes(author.id)) this.updatedThreads.push(author.id); - this.queue.push(author.id); + if(!this.queue.includes(author.id)) this.queue.push(author.id); await channel.send({ embed }).catch((err) => { this.client.logger.error(`channel.send errored:\n${err.stack}\nContent: "${content}"`); @@ -185,115 +177,6 @@ class Modmail { } - /** - * Process channels for incoming modmail - * - * @param {GuildMember} member - * @param {string} id - * @param {Array} history - * @return {TextChannel} - * @memberof Modmail - */ - loadChannel(member, history) { - - if (this.awaitingChannel[member.id]) return this.awaitingChannel[member.id]; - // eslint-disable-next-line no-async-promise-executor - const promise = new Promise(async (resolve, reject) => { - - const channelID = this.client.cache.channels[member.id]; - const guild = this.mainServer; - const { user } = member; - let channel = guild.channels.resolve(channelID); - const { context } = this.client._options; - - if (this.newMail.children.size >= 45) this.overflow(); - - if (!channel) { // Create and populate channel - channel = await guild.channels.create(`${user.username}_${user.discriminator}`, { - parent: this.newMail.id - }); - - // Start with user info embed - const embed = { - author: { name: user.tag }, - thumbnail: { - url: user.displayAvatarURL({ dynamic: true }) - }, - fields: [ - { - name: '__User Data__', - value: `**User:** <@${user.id}>\n` + - `**Account created:** ${user.createdAt.toDateString()}\n`, - inline: false - } - ], - footer: { text: `• User ID: ${user.id}` }, - color: guild.me.highestRoleColor - }; - if (member.banned) embed.description = `**__USER IS IN BANLAND__**`; - else embed.fields.push({ - name: '__Member Data__', - value: `**Nickname:** ${member.nickname || 'N/A'}\n` + - `**Server join date:** ${member.joinedAt.toDateString()}\n` + - `**Roles:** ${member.roles.cache.map((r) => `<@&${r.id}>`).join(' ')}`, - inline: false - }); - await channel.send({ embed }); - - // Load in context - const len = history.length; - for (let i = context < len ? context : len; i > 0; i--) { - const entry = history[len - i]; - if (!entry) continue; - if (entry.markread) continue; - - const user = await this.client.resolveUser(entry.author).catch(this.client.logger.error.bind(this.client.logger)); - const mem = await this.getMember(user.id).catch(this.client.logger.error.bind(this.client.logger)); - if (!user) return reject(new Error(`Failed to find user`)); - - const embed = { - footer: { - text: user.id - }, - author: { - name: user.tag + (entry.anon ? ' (ANONYMOUS REPLY)' : ''), - // eslint-disable-next-line camelcase - icon_url: user.displayAvatarURL({ dynamic: true }) - }, - description: entry.content, - color: mem?.highestRoleColor || 0, - fields: [], - timestamp: new Date(entry.timestamp) - }; - - if (entry.attachments && entry.attachments.length) embed.fields.push({ - name: '__Attachments__', - value: entry.attachments.join('\n').substring(0, 1000) - }); - - await channel.send({ embed }); - - } - - this.client.cache.channels[user.id] = channel.id; - - } - - // Ensure the right category - //if (channel.parentID !== this.newMail.id) - await channel.edit({ parentID: this.newMail.id, lockPermissions: true }).catch((err) => { - this.client.logger.error(`Error during channel transition:\n${err.stack}`); - }); - delete this.awaitingChannel[user.id]; - resolve(channel); - - }); - - this.awaitingChannel[member.id] = promise; - return promise; - - } - async sendCannedResponse({ message, responseName, anon }) { const content = this.getCanned(responseName); @@ -306,6 +189,7 @@ class Modmail { } + // Send reply from channel async sendResponse({ message, content, anon }) { const { channel, member, author } = message; @@ -314,7 +198,8 @@ class Modmail { msg: `This command only works in modmail channels.` }; - const chCache = this.client.cache.channels; + // Resolve target user from cache + const chCache = this.cache.channels; const result = Object.entries(chCache).find(([, val]) => { return val === channel.id; }); @@ -324,19 +209,15 @@ class Modmail { msg: `This doesn't seem to be a valid modmail channel. Cache might be out of sync. **[MISSING TARGET]**` }; + // Ensure target exists, this should never run into issues const [userId] = result; const targetUser = await this.getUser(userId); - //const targetMember = await this.getMember(userId); if (!targetUser) return { error: true, msg: `User seems to have left.\nReport this if the user is still present.` }; - const history = await this.loadHistory(userId); - history.push({ author: member.id, content, timestamp: Date.now(), isReply: true, anon }); - if (this.queue.includes(userId)) this.queue.splice(this.queue.indexOf(userId), 1); - const embed = { author: { name: anon ? `${this.mainServer.name.toUpperCase()} STAFF` : author.tag, @@ -347,6 +228,7 @@ class Modmail { color: anon ? this.anonColor : member.highestRoleColor }; + // Send to target user const sent = await targetUser.send({ embed }).catch((err) => { this.client.logger.warn(`Error during DMing user: ${err.message}`); return { @@ -354,7 +236,7 @@ class Modmail { msg: `Failed to send message to target.` }; }); - + // Should only error if user has DMs off or has left all mutual servers if (sent.error) return sent; if (anon) embed.author = { @@ -362,19 +244,13 @@ class Modmail { // eslint-disable-next-line camelcase icon_url: author.displayAvatarURL({ dynamic: true }) }; - await channel.send({ embed }).catch((err) => { - this.client.logger.error(`channel.send errored:\n${err.stack}\nContent: "${content}"`); - }); - if (this.readMail.children.size > 45) this.sweepChannels({ count: 5, force: true }); - await channel.edit({ parentID: this.readMail.id, lockPermissions: true }).catch((err) => { - this.client.logger.error(`Error during channel transition:\n${err.stack}`); - }); + await this.channels.send(targetUser, embed, { author: member.id, content, timestamp: Date.now(), isReply: true, anon }); await message.delete().catch(this.client.logger.warn.bind(this.client.logger)); - if (!this.updatedThreads.includes(userId)) this.updatedThreads.push(userId); } + // Send modmail with the modmail command async sendModmail({ message, content, anon, target }) { const targetMember = await this.getMember(target.id); @@ -383,16 +259,6 @@ class Modmail { msg: `Cannot find member` }; - 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 (history.error) return { - error: true, - msg: `Internal error, this has been logged.` - }; - const { author, member } = message; const embed = { @@ -405,6 +271,7 @@ class Modmail { color: anon ? this.anonColor : member.highestRoleColor }; + // Dm the user const sent = await target.send({ embed }).catch((err) => { this.client.logger.warn(`Error during DMing user: ${err.message}`); return { @@ -414,105 +281,32 @@ class Modmail { }); if (sent.error) return sent; + // Inline response await message.channel.send('Delivered.').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.queue.includes(target.id)) this.queue.splice(this.queue.indexOf(target.id), 1); - if (!this.updatedThreads.includes(target.id)) this.updatedThreads.push(target.id); - await channel.send({ embed }).catch(this.client.logger.error.bind(this.client.logger)); - await channel.edit({ parentID: this.readMail.id, lockPermissions: true }).catch(this.client.logger.error.bind(this.client.logger)); + + // Send to channel in server + await this.channels.send(targetMember, embed, { author: member.id, content, timestamp: Date.now(), isReply: true, anon }); } - /** - * - * - * @param {object} { age, count, force } age: how long the channel has to be without activity to be deleted, count: how many channels to act on, force: whether to ignore answered status - * @memberof Modmail - */ - async sweepChannels({ age, count, force = false } = {}) { + async markread(message, args) { - this.client.logger.info(`Sweeping graveyard`); - const now = Date.now(); - const graveyardChannels = this.graveyard.children.sort((a, b) => { - if (!a.lastMessage) return -1; - if (!b.lastMessage) return 1; - return a.lastMessage.createdTimestamp - b.lastMessage.createdTimestamp; - }).array(); + const { author } = message; - let channelCount = 0; - if (!age) age = this.graveyardInactive * 60 * 1000; // 1 hour - for (const channel of graveyardChannels) { - - const { lastMessage } = channel; - if (!lastMessage || now - lastMessage.createdTimestamp > age || count && channelCount <= count) { - await channel.delete().then((ch) => { - const chCache = this.client.cache.channels; - const _cached = Object.entries(chCache).find(([, val]) => { - return val === ch.id; - }); - if (_cached) { - const [userId] = _cached; - delete chCache[userId]; - } - }).catch((err) => { - this.client.logger.error(`Failed to delete channel from graveyard during sweep:\n${err.stack}`); - }); - channelCount++; - } - - } - - this.client.logger.info(`Swept ${channelCount} channels from graveyard, cleaning up answered...`); - - const answered = this.readMail.children - .filter((channel) => !channel.lastMessage || channel.lastMessage.createdTimestamp < Date.now() - this.readInactive * 60 * 1000 || force) - .sort((a, b) => { - if (!a.lastMessage) return -1; - if (!b.lastMessage) return 1; - return a.lastMessage.createdTimestamp - b.lastMessage.createdTimestamp; - }).array(); - let chCount = this.graveyard.children.size; - for (const ch of answered) { - if (chCount < 50) { - await ch.edit({ parentID: this.graveyard.id, lockPermissions: true }).catch((err) => { - this.client.logger.error(`Failed to move channel to graveyard during sweep:\n${err.stack}`); - }); - chCount++; - } else break; - } - - this.client.logger.info(`Sweep done. Took ${Date.now() - now}ms`); - - } - - async overflow() { // Overflows new modmail category into read - const channels = this.newMail.children.sort((a, b) => { - if (!a.lastMessage) return -1; - if (!b.lastMessage) return 1; - return a.lastMessage.createdTimestamp - b.lastMessage.createdTimestamp; - }).array(); - - if (this.readMail.children.size >= 45) await this.sweepChannels({ count: 5, force: true }); - - let counter = 0; - for (const channel of channels) { - await channel.edit({ parentID: this.readMail.id, lockPermissions: true }); - counter++; - if (counter === 5) break; - } - } - - async markread(message) { - - const { channel, author } = message; - - if (!this.categories.includes(channel.parentID)) return { + if (!this.categories.includes(message.channel.parentID) && !args.length) return { error: true, - msg: `This command only works in modmail channels.` + msg: `This command only works in modmail channels without arguments.` }; - const chCache = this.client.cache.channels; + // Eventually support marking several threads read at the same time + const [id] = args; + let channel = null; + const _user = await this.client.resolveUser(id, true); + if (!args.length) ({ channel } = message); + else if (this.cache.channels[_user.id]) channel = this.client.channels.resolve(this.cache.channels[_user.id]); + else channel = await this.client.resolveChannel(id); + + const chCache = this.cache.channels; const result = Object.entries(chCache).find(([, val]) => { return val === channel.id; }); @@ -523,21 +317,9 @@ class Modmail { }; 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 - if (this.queue.includes(userId)) this.queue.splice(this.queue.indexOf(userId), 1); - - await channel.edit({ parentID: this.readMail.id, lockPermissions: true }); - if (!this.updatedThreads.includes(author.id)) this.updatedThreads.push(userId); - return `Done`; + const response = await this.channels.markread(userId, channel, author); + if (response.error) return response; + return 'Done'; } @@ -559,48 +341,6 @@ class Modmail { } - loadHistory(userId) { - - return new Promise((resolve, reject) => { - - if (this.mmcache[userId]) return resolve(this.mmcache[userId]); - - const path = `./modmail_cache/${userId}.json`; - if (!fs.existsSync(path)) { - this.mmcache[userId] = []; - return resolve(this.mmcache[userId]); - } - - fs.readFile(path, { encoding: 'utf-8' }, (err, data) => { - if (err) reject(err); - const parsed = JSON.parse(data); - this.mmcache[userId] = parsed; - resolve(parsed); - }); - - }); - - } - - saveHistory() { - - if (!this.updatedThreads.length) return; - const toSave = [...this.updatedThreads]; - this.updatedThreads = []; - this.client.logger.debug(`Saving modmail data`); - if (!fs.existsSync('./modmail_cache')) fs.mkdirSync('./modmail_cache'); - - for (const id of toSave) { - const path = `./modmail_cache/${id}.json`; - try { - fs.writeFileSync(path, JSON.stringify(this.mmcache[id])); - } catch (err) { - this.client.logger.error(`Error during saving of history\n${id}\n${JSON.stringify(this.mmcache)}\n${err.stack}`); - } - } - - } - getCanned(name) { return this.replies[name.toLowerCase()]; }