sooo much bullshit
This commit is contained in:
parent
716801e7a0
commit
05eb2ef0f1
4
.gitignore
vendored
4
.gitignore
vendored
@ -6,4 +6,6 @@ node_modules
|
||||
|
||||
# Logs & cache
|
||||
logs
|
||||
modmail_cache
|
||||
modmail_cache
|
||||
persistent_cache.json
|
||||
canned_replies.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",
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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' }));
|
||||
|
||||
}
|
||||
|
||||
|
@ -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
77
structure/Resolver.js
Normal 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;
|
31
structure/commands/CannedReply.js
Normal file
31
structure/commands/CannedReply.js
Normal 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;
|
19
structure/commands/Markread.js
Normal file
19
structure/commands/Markread.js
Normal 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;
|
50
structure/commands/Modmail.js
Normal file
50
structure/commands/Modmail.js
Normal 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;
|
31
structure/commands/Reply.js
Normal file
31
structure/commands/Reply.js
Normal 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;
|
19
structure/extensions/Channel.js
Normal file
19
structure/extensions/Channel.js
Normal 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;
|
15
structure/extensions/Member.js
Normal file
15
structure/extensions/Member.js
Normal 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;
|
4
structure/extensions/index.js
Normal file
4
structure/extensions/index.js
Normal file
@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
TextChannel: require('./Channel'),
|
||||
GuildMember: require('./Member')
|
||||
};
|
Loading…
Reference in New Issue
Block a user