diff --git a/.gitignore b/.gitignore index e856a79..06337ea 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ node_modules # Logs & cache logs -modmail_cache \ No newline at end of file +modmail_cache +persistent_cache.json +canned_replies.json \ No newline at end of file diff --git a/package.json b/package.json index 99c32b6..7f2d28c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "description": "Modmail bot with eventual integration with Galactic Bot's API", "scripts": { "start": "node index.js", - "dev": "nodemon index.js" + "dev": "nodemon --ignore *.json index.js" }, "devDependencies": { "eslint": "^7.28.0", diff --git a/structure/Client.js b/structure/Client.js index 19d35c4..8b5d3a9 100644 --- a/structure/Client.js +++ b/structure/Client.js @@ -1,8 +1,12 @@ const { Client } = require('discord.js'); +const fs = require('fs'); +// eslint-disable-next-line no-unused-vars +const { TextChannel, GuildMember } = require('./extensions'); const { Logger } = require('../logger'); const Modmail = require('./Modmail'); const Registry = require('./Registry'); +const Resolver = require('./Resolver'); class ModmailClient extends Client { @@ -13,36 +17,118 @@ class ModmailClient extends Client { this._options = options; this._ready = false; + 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.on('ready', () => { this.logger.info(`Client ready, logged in as ${this.user.tag}`); }); + this.cache = null; + } async init() { - this.logger.info(`Logging in`); - await this.login(this._options.discordToken); - this.logger.info(`Starting up modmail`); - this.modmail.init(); this.registry.loadCommands(); 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.logger.info(`Logging in`); + await this.login(this._options.discordToken); + await this.ready(); + + this.mainServer = this.guilds.cache.get(this._options.mainGuild); + this.bansServer = this.guilds.cache.get(this._options.bansGuild) || null; + this.logger.info(`Starting up modmail handler`); + this.modmail.init(); + + process.on('exit', this.saveCache.bind(this)); + process.on('SIGINT', this.saveCache.bind(this)); + this._ready = true; + this.cacheSaver = setInterval(this.saveCache.bind(this), 1 * 60 * 1000); + + } + + saveCache() { + this.logger.debug('Saving cache'); + delete this.cache._channels; + fs.writeFileSync('./persistent_cache.json', JSON.stringify(this.cache)); + } + + ready() { + + return new Promise((resolve) => { + if (this._ready) resolve(); + this.once('ready', resolve); + }); + } async handleMessage(message) { + if (!this._ready) return; + if (message.author.bot) return; + + // No command handling in dms, at least for now if (!message.guild) return this.modmail.handleUser(message); - const { channel, guild } = message; + const { prefix } = this; + const { channel, guild, content, member } = message; + if (guild.id !== this.mainServer.id) return; + if (!content || !content.startsWith(prefix)) return; + const roles = member.roles.cache.map((r) => r.id); + 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 command = this.registry.find(commandName); + if (!command) return; + message._caller = commandName; + + if (command.showUsage && !args.length) { + + let helpStr = `**${command.name}**\nUsage: ${this.prefix}${command.name} ${command.usage}`; + if (command.aliases) helpStr += `\nAliases: ${command.aliases.join(', ')}`; + return channel.send(helpStr).catch(this.logger.error.bind(this.logger)); + + } + + this.logger.debug(`Executing command ${command.name}`); + const result = await command.execute(message, args).catch((err) => { + this.logger.error(`Command ${command.name} errored during execution:\n${err.stack}`); + return { + error: true, + msg: `Command ${command.name} ran into an error during execution. This has been logged.` + }; + }); + + if (!result) return; + + if (result.error) return channel.send(result.msg).catch(this.logger.error.bind(this.logger)); + else if (result.response) return channel.send(result.response).catch(this.logger.error.bind(this.logger)); + else if (typeof result === 'string') return channel.send(result).catch(this.logger.error.bind(this.logger)); + + } + + resolveUser(input) { + return this.resolver.resolveUser(input); } } diff --git a/structure/Modmail.js b/structure/Modmail.js index a35ec73..0cc6024 100644 --- a/structure/Modmail.js +++ b/structure/Modmail.js @@ -1,3 +1,5 @@ +const fs = require('fs'); + class Modmail { constructor(client) { @@ -5,28 +7,516 @@ class Modmail { this.client = client; this.mainServer = null; this.bansServer = null; + this.categories = client._options.modmailCategory; + this.timeout = null; + this.updatedThreads = []; + this.mmcache = {}; + this.spammers = {}; + this.replies = {}; + this.awaitingChannel = {}; } init() { - const { bansGuild, mainGuild } = this.client._options; - - this.mainServer = this.client.guilds.cache.get(mainGuild); - if (!this.mainServer) throw new Error(`Missing main server: ${mainGuild} is not a valid server ID`); + this.mainServer = this.client.mainServer; + if (!this.mainServer) throw new Error(`Missing main server`); - this.bansServer = this.client.guilds.cache.get(bansGuild) || null; - this.client.logger.warn(`Missing bans server: ${bansGuild} is not a valid server ID`); + this.bansServer = this.client.bansServer; + if (!this.bansServer) this.client.logger.warn(`Missing bans server`); + + 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(); + + // Sweep graveyard every 30 min and move stale channels to graveyard + this.sweeper = setInterval(this.sweepChannels.bind(this), 5 * 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 + + } + + async getMember(user) { + + let result = this.mainServer.members.cache.get(user); + if(!result) result = await this.mainServer.members.fetch(user).catch(() => { + return null; + }); + + if (!result && this.bansServer) { + result = this.bansServer.members.cache.get(user); + if(!result) result = await this.bansServer.members.fetch(user).catch(() => { + return null; + }); + if (result) result.banned = true; + } + + return result; + + } + + async getUser(user) { + + let result = this.client.users.cache.get(user); + if (!result) result = await this.client.users.fetch(user).catch(() => { + return null; + }); + return result; } async handleUser(message) { + const { author, content } = message; + const member = await this.getMember(author.id); + 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) { + await author.send(`Thank you for your message, we'll get back to you soon!`); + } + 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 }; + else { + if (this.spammers[author.id].timeout) { + if (now - this.spammers[author.id].start > 5 * 60) this.spammers[author.id] = { start: now, count: 1, timeout: false, warned: false }; + else return; + } else if (this.spammers[author.id].count > 5 && now - this.spammers[author.id].start < 15) { + this.spammers[author.id].timeout = true; + if (!this.spammers[author.id].warned) { + this.spammers[author.id].warned = true; + await author.send(`I've blocked you for spamming, please try again in 5 minutes`); + if (cache._channels[author.id]) await cache._channels[author.id].send(`I've blocked ${author.tag} from DMing me as they were spamming.`); + } + } else { + if (now - this.spammers[author.id].start > 15) this.spammers[author.id] = { start: now, count: 1, timeout: false, warned: false }; + else this.spammers[author.id].count++; + } + } + + const pastModmail = await this.loadHistory(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) + .catch((err) => { + this.client.logger.error(`Error during channel handling:\n${err.stack}`); + return { error: true }; + }); + if (channel.error) return author.send(`Internal error, this has been logged.`); + 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 }); + if (!this.updatedThreads.includes(author.id)) this.updatedThreads.push(author.id); + + const embed = { + footer: { + text: member.id + }, + author: { + name: member.user.tag, + // eslint-disable-next-line camelcase + icon_url: member.user.displayAvatarURL({ dynamic: true }) + }, + // eslint-disable-next-line no-nested-ternary + description: content && content.length ? content.length > 2000 ? `${content.substring(0, 2000)}...\n\n**Content cut off**` : content : `**__MISSING CONTENT__**`, + color: member.highestRoleColor, + fields: [], + timestamp: new Date() + }; + + if (message.attachments.size) embed.fields.push({ + name: '__Attachments__', + value: message.attachments.map((att) => att.url).join('\n').substring(0, 1000) + }); + + await channel.send({ embed }).catch((err) => { + 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); } - async handleServer() { + /** + * 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) => { + + 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 + }); + + 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: 479397 + }; + 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 }); + + const len = history.length; + for (let i = context < len ? context : len; i > 0; i--) { + const entry = history[len - i]; + if (!entry) continue; + + const mem = entry.author.id === member.id ? member : this.mainServer.members.resolve(entry.author); + + const embed = { + footer: { + text: mem.id + }, + author: { + name: mem.user.tag + (entry.anon ? ' (ANONYMOUS REPLY)' : ''), + // eslint-disable-next-line camelcase + icon_url: mem.user.displayAvatarURL({ dynamic: true }) + }, + description: entry.content, + color: mem.highestRoleColor, + 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 }).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 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 }); + counter++; + if (counter === 5) break; + } + } + + async sendCannedResponse({ message, responseName, anon }) { + + const content = this.getCanned(responseName); + if (!content) return { + error: true, + msg: `No canned reply by the name \`${responseName}\` exists` + }; + + return this.sendResponse({ message, content, anon }); + + } + + async sendResponse({ message, content, anon }) { + + const { channel, member, 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 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 }); + + const embed = { + author: { + name: anon ? `${this.mainServer.name.toUpperCase()} STAFF` : author.tag, + // eslint-disable-next-line camelcase + icon_url: anon ? this.mainServer.iconURL({ dynamic: true }) : author.displayAvatarURL({ dynamic: true }) + }, + description: content, + color: member.highestRoleColor + }; + + const sent = await targetUser.send({ embed }).catch((err) => { + this.client.logger.warn(`Error during DMing user: ${err.message}`); + return { + error: true, + msg: `Failed to send message to target.` + }; + }); + + if (sent.error) return sent; + + 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 }).catch((err) => { + this.client.logger.error(`Error during channel transition:\n${err.stack}`); + }); + await message.delete().catch(this.client.logger.warn.bind(this.client.logger)); + + } + + async sendModmail({ message, content, anon, target }) { + + console.log(content, anon, target.tag); + + const targetMember = await this.getMember(target.id); + if (!targetMember) return { + error: true, + msg: `Cannot find member` + }; + + const pastModmail = 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 { + error: true, + msg: `Internal error, this has been logged.` + }; + + const { author, member } = message; + + const embed = { + author: { + name: anon ? `${this.mainServer.name.toUpperCase()} STAFF` : author.tag, + // eslint-disable-next-line camelcase + icon_url: anon ? this.mainServer.iconURL({ dynamic: true }) : author.displayAvatarURL({ dynamic: true }) + }, + description: content, + color: member.highestRoleColor + }; + + const sent = await target.send({ embed }).catch((err) => { + this.client.logger.warn(`Error during DMing user: ${err.message}`); + return { + error: true, + msg: `Failed to send message to target.` + }; + }); + 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)); + 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)); + + } + + /** + * + * + * @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 } = {}) { + + 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(); + + let channelCount = 0; + if (!age) age = 5 * 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.answered && channel.lastMessage.createdTimestamp < Date.now() - 15 * 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 }).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 markread(message) { + + const { channel } = message; + + if (!this.categories.includes(channel.parentID)) return { + error: true, + msg: `This command only works in modmail channels.` + }; + + await channel.edit({ parentID: this.readMail.id }); + return `Done`; + + } + + 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)) return resolve([]); + + fs.readFile(path, { encoding: 'utf-8' }, (err, data) => { + if (err) reject(err); + resolve(JSON.parse(data)); + }); + + }); + + } + + saveHistory() { + + if (!this.updatedThreads.length) return; + const toSave = [...this.updatedThreads]; + this.updatedThreads = []; + this.client.logger.debug(`Saving modmail data`); + + for (const id of toSave) { + const path = `./modmail_cache/${id}.json`; + fs.writeFileSync(path, JSON.stringify(this.mmcache[id])); + } + + } + + getCanned(name) { + return this.replies[name.toLowerCase()]; + } + + loadReplies() { + + if (!fs.existsSync('./canned_replies.json')) return {}; + return JSON.parse(fs.readFileSync('./canned_replies.json', { encoding: 'utf-8' })); } diff --git a/structure/Registry.js b/structure/Registry.js index 79f117e..44755de 100644 --- a/structure/Registry.js +++ b/structure/Registry.js @@ -12,6 +12,12 @@ class Registry { } + find(name) { + + return this.commands.find((c) => c.name === name || c.aliases?.includes(name)); + + } + loadCommands() { const commandsDir = path.join(process.cwd(), 'structure', 'commands'); diff --git a/structure/Resolver.js b/structure/Resolver.js new file mode 100644 index 0000000..5cca711 --- /dev/null +++ b/structure/Resolver.js @@ -0,0 +1,77 @@ +class Resolver { + + constructor(client) { + this.client = client; + } + + /** + * Resolve several user resolveables + * + * @param {Array} [resolveables=[]] an array of user resolveables (name, id, tag) + * @param {Boolean} [strict=false] whether or not to attempt resolving by partial usernames + * @returns {Promise> || boolean} Array of resolved users or false if none were resolved + * @memberof Resolver + */ + async resolveUsers(resolveables = [], strict = false) { + + if (typeof resolveables === 'string') resolveables = [resolveables]; + if (resolveables.length === 0) return false; + const { users } = this.client; + const resolved = []; + + for (const resolveable of resolveables) { + + if ((/<@!?([0-9]{17,21})>/u).test(resolveable)) { + + const [, id] = resolveable.match(/<@!?([0-9]{17,21})>/u); + const user = await users.fetch(id).catch((err) => { + if (err.code === 10013) return false; + // this.client.logger.warn(err); return false; + + }); + if (user) resolved.push(user); + + } else if ((/(id:)?([0-9]{17,21})/u).test(resolveable)) { + + const [, , id] = resolveable.match(/(id:)?([0-9]{17,21})/u); + const user = await users.fetch(id).catch((err) => { + if (err.code === 10013) return false; + // this.client.logger.warn(err); return false; + + }); + if (user) resolved.push(user); + + } else if ((/^@?([\S\s]{1,32})#([0-9]{4})/u).test(resolveable)) { + + const m = resolveable.match(/^@?([\S\s]{1,32})#([0-9]{4})/u); + const username = m[1].toLowerCase(); + const discrim = m[2].toLowerCase(); + const user = users.cache.sort((a, b) => a.username.length - b.username.length).filter((u) => u.username.toLowerCase() === username && u.discriminator === discrim).first(); + if (user) resolved.push(user); + + } else if (!strict) { + + const name = resolveable.toLowerCase(); + const user = users.cache.sort((a, b) => a.username.length - b.username.length).filter((u) => u.username.toLowerCase().includes(name)).first(); + if (user) resolved.push(user); + + } + + } + + return resolved.length ? resolved : false; + + } + + async resolveUser(resolveable, strict) { + + if (!resolveable) return false; + if (resolveable instanceof Array) throw new Error('Resolveable cannot be of type Array, use resolveUsers for resolving arrays of users'); + const result = await this.resolveUsers([resolveable], strict); + return result ? result[0] : false; + + } + +} + +module.exports = Resolver; \ No newline at end of file diff --git a/structure/commands/CannedReply.js b/structure/commands/CannedReply.js new file mode 100644 index 0000000..efb4a50 --- /dev/null +++ b/structure/commands/CannedReply.js @@ -0,0 +1,31 @@ +const Command = require('../Command'); + +class CannedReply extends Command { + + constructor(client) { + super(client, { + name: 'cannedreply', + aliases: ['cr', 'canned'], + showUsage: true, + usage: `` + }); + } + + async execute(message, args) { + + const [first] = args.map((a) => a); + // eslint-disable-next-line prefer-const + let { content, _caller } = message, + anon = false; + content = content.replace(`${this.client.prefix}${_caller}`, ''); + if (first.toLowerCase() === 'anon') { + anon = true; + content = content.replace(first, ''); + } + return this.client.modmail.sendCannedResponse({ message, responseName: content.trim(), anon }); + + } + +} + +module.exports = CannedReply; \ No newline at end of file diff --git a/structure/commands/Markread.js b/structure/commands/Markread.js new file mode 100644 index 0000000..f35e4b6 --- /dev/null +++ b/structure/commands/Markread.js @@ -0,0 +1,19 @@ +const Command = require('../Command'); + +class Markread extends Command { + + constructor(client) { + super(client, { + name: 'markread' + }); + } + + async execute(message, args) { + + return this.client.modmail.markread(message); + + } + +} + +module.exports = Markread; \ No newline at end of file diff --git a/structure/commands/Modmail.js b/structure/commands/Modmail.js new file mode 100644 index 0000000..e5fd915 --- /dev/null +++ b/structure/commands/Modmail.js @@ -0,0 +1,50 @@ +const Command = require('../Command'); + +class Modmail extends Command { + + constructor(client) { + super(client, { + name: 'modmail', + aliases: ['mm'], + showUsage: true, + usage: ` ` + }); + } + + async execute(message, args) { + + // eslint-disable-next-line prefer-const + let [first, second] = args.map((a) => a); + // eslint-disable-next-line prefer-const + let { content, _caller } = message, + anon = false; + content = content.replace(`${this.client.prefix}${_caller}`, ''); + if (first.toLowerCase() === 'anon') { + anon = true; + content = content.replace(first, ''); + first = second; + } + + const user = await this.client.resolveUser(first); + if (!user) return { + error: true, + msg: 'Failed to resolve user' + }; + else if (user.bot) return { + error: true, + msg: 'Cannot send modmail to a bot.' + }; + content = content.replace(first, ''); + + if (!content.length) return { + error: true, + msg: `Cannot send empty message` + }; + + return this.client.modmail.sendModmail({ message, content: content.trim(), anon, target: user }); + + } + +} + +module.exports = Modmail; \ No newline at end of file diff --git a/structure/commands/Reply.js b/structure/commands/Reply.js new file mode 100644 index 0000000..7a8e7b2 --- /dev/null +++ b/structure/commands/Reply.js @@ -0,0 +1,31 @@ +const Command = require('../Command'); + +class Reply extends Command { + + constructor(client) { + super(client, { + name: 'reply', + aliases: ['r'], + showUsage: true, + usage: `` + }); + } + + async execute(message, args) { + + const [first] = args.map((a) => a); + // eslint-disable-next-line prefer-const + let { content, _caller } = message, + anon = false; + content = content.replace(`${this.client.prefix}${_caller}`, ''); + if (first.toLowerCase() === 'anon') { + anon = true; + content = content.replace(first, ''); + } + return this.client.modmail.sendResponse({ message, content: content.trim(), anon }); + + } + +} + +module.exports = Reply; \ No newline at end of file diff --git a/structure/extensions/Channel.js b/structure/extensions/Channel.js new file mode 100644 index 0000000..ee82378 --- /dev/null +++ b/structure/extensions/Channel.js @@ -0,0 +1,19 @@ +const { Structures } = require('discord.js'); + +const TextChannel = Structures.extend('TextChannel', (TextChannel) => { + + return class ExtendedTextChannel extends TextChannel { + + constructor(guild, data) { + super(guild, data); + + this.answered = false; + this.recipient = null; + + } + + }; + +}); + +module.exports = TextChannel; \ No newline at end of file diff --git a/structure/extensions/Member.js b/structure/extensions/Member.js new file mode 100644 index 0000000..27d9379 --- /dev/null +++ b/structure/extensions/Member.js @@ -0,0 +1,15 @@ +const { Structures } = require('discord.js'); + +const Member = Structures.extend('GuildMember', (GuildMember) => { + + return class ExtendedMember extends GuildMember { + get highestRoleColor() { + const role = this.roles.cache.filter((role) => role.color !== 0).sort((a, b) => b.rawPosition - a.rawPosition).first(); + if (role) return role.color; + return 0; + } + }; + +}); + +module.exports = Member; \ No newline at end of file diff --git a/structure/extensions/index.js b/structure/extensions/index.js new file mode 100644 index 0000000..0c2a209 --- /dev/null +++ b/structure/extensions/index.js @@ -0,0 +1,4 @@ +module.exports = { + TextChannel: require('./Channel'), + GuildMember: require('./Member') +}; \ No newline at end of file