sooo much bullshit

This commit is contained in:
Erik 2021-06-19 16:06:20 +03:00
parent 716801e7a0
commit 05eb2ef0f1
No known key found for this signature in database
GPG Key ID: 7E862371D3409F16
13 changed files with 844 additions and 14 deletions

4
.gitignore vendored
View File

@ -6,4 +6,6 @@ node_modules
# Logs & cache
logs
modmail_cache
modmail_cache
persistent_cache.json
canned_replies.json

View File

@ -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",

View File

@ -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);
}
}

View File

@ -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<object>} 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' }));
}

View File

@ -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');

77
structure/Resolver.js Normal file
View File

@ -0,0 +1,77 @@
class Resolver {
constructor(client) {
this.client = client;
}
/**
* Resolve several user resolveables
*
* @param {Array<String>} [resolveables=[]] an array of user resolveables (name, id, tag)
* @param {Boolean} [strict=false] whether or not to attempt resolving by partial usernames
* @returns {Promise<Array<User>> || 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;

View File

@ -0,0 +1,31 @@
const Command = require('../Command');
class CannedReply extends Command {
constructor(client) {
super(client, {
name: 'cannedreply',
aliases: ['cr', 'canned'],
showUsage: true,
usage: `<canned response name>`
});
}
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;

View File

@ -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;

View File

@ -0,0 +1,50 @@
const Command = require('../Command');
class Modmail extends Command {
constructor(client) {
super(client, {
name: 'modmail',
aliases: ['mm'],
showUsage: true,
usage: `<user> <content>`
});
}
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;

View File

@ -0,0 +1,31 @@
const Command = require('../Command');
class Reply extends Command {
constructor(client) {
super(client, {
name: 'reply',
aliases: ['r'],
showUsage: true,
usage: `<reply content>`
});
}
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;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,4 @@
module.exports = {
TextChannel: require('./Channel'),
GuildMember: require('./Member')
};