modmail/structure/Modmail.js

455 lines
17 KiB
JavaScript
Raw Normal View History

2021-06-19 15:06:20 +02:00
const fs = require('fs');
const ChannelHandler = require('./ChannelHandler');
2021-06-19 15:06:20 +02:00
2021-06-18 15:41:57 +02:00
class Modmail {
2021-06-19 23:57:12 +02:00
// A lot of this can probably be simplified but I wrote all of this in 2 days and I cba to fix this atm
// TODO: Fix everything
2021-10-22 09:35:04 +02:00
constructor (client) {
2021-06-18 15:41:57 +02:00
this.client = client;
this.cache = client.cache;
2021-06-18 15:41:57 +02:00
this.mainServer = null;
this.bansServer = null;
this.logChannel = null;
this.reminderChannel = null;
2021-06-19 21:31:51 +02:00
2021-06-19 23:57:12 +02:00
const opts = client._options;
this.anonColor = opts.anonColor;
this.reminderInterval = opts.modmailReminderInterval || 30;
this._reminderChannel = opts.modmailReminderChannel || null;
this._logChannel = opts.logChannel || null;
this.categories = opts.modmailCategory;
2022-01-27 14:01:41 +01:00
this.inlineResponse = opts.inlineResponse || `Thank you for your message, we'll get back to you soon!`;
2021-06-19 21:16:36 +02:00
2021-06-19 15:06:20 +02:00
this.updatedThreads = [];
2021-06-19 23:57:12 +02:00
this.queue = [];
2021-06-19 15:06:20 +02:00
this.spammers = {};
this.replies = {};
2021-06-18 15:41:57 +02:00
2021-06-19 23:57:12 +02:00
this.lastReminder = null;
2021-12-22 10:30:50 +01:00
this.disabled = false;
this.disabledReason = null;
2021-06-19 23:57:12 +02:00
this.channels = new ChannelHandler(this, opts);
this._ready = false;
2021-06-18 15:41:57 +02:00
}
2021-10-22 09:35:04 +02:00
async init () {
2021-06-18 15:41:57 +02:00
2021-06-19 15:06:20 +02:00
this.mainServer = this.client.mainServer;
if (!this.mainServer) throw new Error(`Missing main server`);
2021-06-18 15:41:57 +02:00
2021-06-19 15:06:20 +02:00
this.bansServer = this.client.bansServer;
if (!this.bansServer) this.client.logger.warn(`Missing bans server`);
2021-06-19 22:59:42 +02:00
if (!this.anonColor) this.anonColor = this.mainServer.me.highestRoleColor;
2021-06-19 15:06:20 +02:00
this.replies = this.loadReplies();
this.queue = this.client.cache.queue;
2021-06-19 23:57:12 +02:00
if (this._reminderChannel) {
this.reminderChannel = this.client.channels.resolve(this._reminderChannel);
this.reminder = setInterval(this.sendReminder.bind(this), this.reminderInterval * 60 * 1000);
2021-07-19 21:30:31 +02:00
this.lastReminder = await this.reminderChannel.messages.fetch(this.cache.misc.lastReminder).catch(() => null);
2021-06-20 01:21:49 +02:00
this.sendReminder();
2021-06-19 23:57:12 +02:00
}
2021-06-19 15:06:20 +02:00
if (this._logChannel) {
this.logChannel = this.client.channels.resolve(this._logChannel);
}
2021-06-19 15:06:20 +02:00
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);
2021-10-22 09:35:04 +02:00
// this.client.logger.info(`Fetching messages from discord for modmail`);
2021-06-19 15:06:20 +02:00
// TODO: Fetch messages from discord in modmail channels
2021-12-22 10:30:50 +01:00
this.disabled = this.cache.misc.disabled || false;
this.disabledReason = this.cache.misc.disabledReason || null;
this.channels.init();
this._ready = true;
2021-06-19 15:06:20 +02:00
}
2021-10-22 09:35:04 +02:00
async getMember (user) {
2021-06-19 15:06:20 +02:00
let result = this.mainServer.members.cache.get(user);
2021-10-22 09:35:04 +02:00
if (!result) result = await this.mainServer.members.fetch(user).catch(() => {
2021-06-19 15:06:20 +02:00
return null;
});
if (!result && this.bansServer) {
result = this.bansServer.members.cache.get(user);
2021-10-22 09:35:04 +02:00
if (!result) result = await this.bansServer.members.fetch(user).catch(() => {
2021-06-19 15:06:20 +02:00
return null;
});
if (result) result.inAppealServer = true;
2021-06-19 15:06:20 +02:00
}
return result;
}
2021-10-22 09:35:04 +02:00
async getUser (user) {
2021-06-19 15:06:20 +02:00
let result = this.client.users.cache.get(user);
if (!result) result = await this.client.users.fetch(user).catch(() => {
return null;
});
return result;
2021-06-18 15:41:57 +02:00
}
2021-10-22 09:35:04 +02:00
async handleUser (message) {
2021-06-18 15:41:57 +02:00
2021-06-19 15:06:20 +02:00
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);
2021-12-22 10:36:06 +01:00
const { cache } = this;
2022-01-03 19:23:55 +01:00
// Anti spam -- never seen user
if (!this.spammers[author.id]) this.spammers[author.id] = {
start: now, // when counting started
count: 1, // # messages
timeout: false, // timed out?
warned: false // warned?
};
else if (this.spammers[author.id].timeout) { // User was timed out, check if 5 minutes have passsed, if so, reset their timeout else ignore them
2021-10-22 09:35:04 +02:00
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) {
2022-01-03 19:23:55 +01:00
// Has sent more than 5 messages in less than 15 seconds at this point, time them out
2021-10-22 09:35:04 +02:00
this.spammers[author.id].timeout = true;
2022-01-03 19:23:55 +01:00
if (!this.spammers[author.id].warned) { // Let them know they've been timed out, toggle the warned property so it doesn't send the warning every time
2021-10-22 09:35:04 +02:00
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.`);
2021-06-19 15:06:20 +02:00
}
2022-01-03 19:23:55 +01:00
} else if (now - this.spammers[author.id].start > 15) this.spammers[author.id] = { start: now, count: 1, timeout: false, warned: false }; // Enough time has passed, reset the object
2021-10-22 09:35:04 +02:00
else this.spammers[author.id].count++;
2021-06-19 15:06:20 +02:00
2021-12-22 10:30:50 +01:00
if (this.disabled) {
let reason = `Modmail has been disabled for the time being`;
if (this.disabledReason) reason += ` for the following reason:\n\n${this.disabledReason}`;
else reason += `.`;
return author.send(reason);
}
2022-01-03 19:23:55 +01:00
const lastActivity = this.cache.lastActivity[author.id];
if (!lastActivity || now - lastActivity > 30 * 60) { // No point in sending this for *every* message
2022-01-27 14:01:41 +01:00
await author.send(this.inlineResponse);
2022-01-03 19:23:55 +01:00
}
this.cache.lastActivity[author.id] = now;
const pastModmail = await this.cache.loadModmailHistory(author.id)
2021-06-19 15:06:20 +02:00
.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.channels.load(member, pastModmail)
2021-06-19 15:06:20 +02:00
.catch((err) => {
this.client.logger.error(`Error during channel handling:\n${err.stack || err}`);
2021-06-19 15:06:20 +02:00
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;
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()
};
2021-06-20 00:32:43 +02:00
const attachments = message.attachments.map((att) => att.url);
if (message.attachments.size) {
embed.fields.push({
name: '__Attachments__',
value: attachments.join('\n').substring(0, 1000)
});
}
2022-03-23 13:39:12 +01:00
pastModmail.push({ attachments, author: author.id, content, timestamp: Date.now(), isReply: false, msgId: message.id });
2021-06-20 00:32:43 +02:00
if (!this.updatedThreads.includes(author.id)) this.updatedThreads.push(author.id);
if (!this.queue.includes(author.id)) this.queue.push(author.id);
this.log({ author, action: `${author.tag} (${author.id}) sent new modmail`, content });
2021-06-19 15:06:20 +02:00
await channel.send({ embed }).catch((err) => {
this.client.logger.error(`channel.send errored:\n${err.stack}\nContent: "${content}"`);
});
}
2021-10-22 09:35:04 +02:00
async sendCannedResponse ({ message, responseName, anon }) {
2021-06-19 15:06:20 +02:00
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 });
}
// Send reply from channel
2021-10-22 09:35:04 +02:00
async sendResponse ({ message, content, anon }) {
2021-06-19 15:06:20 +02:00
const { channel, member, author } = message;
if (!this.categories.includes(channel.parentID)) return {
error: true,
msg: `This command only works in modmail channels.`
};
// Resolve target user from cache
const chCache = this.cache.channels;
2021-10-22 09:35:04 +02:00
const result = Object.entries(chCache).find(([ , val ]) => {
2021-06-19 15:06:20 +02:00
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]**`
};
// Ensure target exists, this should never run into issues
2021-10-22 09:35:04 +02:00
const [ userId ] = result;
const targetMember = await this.getMember(userId);
if (!targetMember) return {
2021-06-19 15:06:20 +02:00
error: true,
msg: `User seems to have left.\nReport this if the user is still present.`
};
2021-06-21 23:36:58 +02:00
this.log({ author, action: `${author.tag} replied to ${targetMember.user.tag}`, content, target: targetMember.user });
2021-06-19 15:06:20 +02:00
await message.delete().catch(this.client.logger.warn.bind(this.client.logger));
2021-11-28 18:31:17 +01:00
return this.send({ target: targetMember, staff: member, content, anon }).catch((err) => this.client.logger.error(`Error during Modmail.send:\n${err.stack}`));
2021-06-19 15:06:20 +02:00
}
// Send modmail with the modmail command
2021-10-22 09:35:04 +02:00
async sendModmail ({ message, content, anon, target }) {
2021-06-19 15:06:20 +02:00
const targetMember = await this.getMember(target.id);
if (!targetMember) return {
error: true,
msg: `Cannot find member.`
2021-06-19 15:06:20 +02:00
};
const { member: staff, author } = message;
// Send to channel in server & target
2021-11-28 18:31:17 +01:00
const sent = await this.send({ target: targetMember, staff, anon, content }).catch((err) => this.client.logger.error(`Error during Modmail.sendModmail:\n${err.stack}`));
if (sent.error) return sent;
// Inline response
2021-11-28 18:31:17 +01:00
await message.channel.send('Delivered.').catch((err) => this.client.logger.error(`Error during Modmail.sendModmail:\n${err.stack}`));
this.log({ author, action: `${author.tag} sent a message to ${targetMember.user.tag}`, content, target: targetMember.user });
}
2021-10-22 09:35:04 +02:00
async send ({ target, staff, anon, content }) {
2021-06-19 15:06:20 +02:00
const embed = {
author: {
name: anon ? `${this.mainServer.name.toUpperCase()} STAFF` : staff.user.tag,
2021-06-19 15:06:20 +02:00
// eslint-disable-next-line camelcase
icon_url: anon ? this.mainServer.iconURL({ dynamic: true }) : staff.user.displayAvatarURL({ dynamic: true })
2021-06-19 15:06:20 +02:00
},
description: content,
color: anon ? this.anonColor : staff.highestRoleColor
2021-06-19 15:06:20 +02:00
};
// Dm the user
2021-06-19 15:06:20 +02:00
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;
if (anon) embed.author = {
name: `${staff.user.tag} (ANON)`,
// eslint-disable-next-line camelcase
icon_url: staff.user.displayAvatarURL({ dynamic: true })
};
2021-06-19 15:06:20 +02:00
return this.channels.send(target, embed, { author: staff.id, content, timestamp: Date.now(), isReply: true, anon });
2021-06-19 15:06:20 +02:00
2021-06-19 21:09:36 +02:00
}
2021-10-22 09:35:04 +02:00
async changeReadState (message, args, state = 'read') {
2021-06-19 15:06:20 +02:00
const { author } = message;
2021-06-19 15:06:20 +02:00
if (!this.categories.includes(message.channel.parentID) && !args.length) return {
2021-06-19 15:06:20 +02:00
error: true,
msg: `This command only works in modmail channels without arguments.`
2021-06-19 15:06:20 +02:00
};
let response = null,
user = null;
2021-06-20 16:43:05 +02:00
if (args.length) {
// Eventually support marking several threads read at the same time
2021-10-22 09:35:04 +02:00
const [ id ] = args;
user = await this.client.resolveUser(id, true);
2021-06-20 16:43:05 +02:00
let channel = await this.client.resolveChannel(id);
if (channel) {
const chCache = this.cache.channels;
2021-10-22 09:35:04 +02:00
const result = Object.entries(chCache).find(([ , val ]) => {
2021-06-20 16:43:05 +02:00
return val === channel.id;
});
if (!result) return {
error: true,
msg: `That doesn't seem to be a valid modmail channel. Cache might be out of sync. **[MISSING TARGET]**`
};
user = await this.client.resolveUser(result[0]);
response = await this.channels.setReadState(user.id, channel, author, state);
2021-06-20 16:43:05 +02:00
} else if (user) {
const _ch = this.cache.channels[user.id];
if (_ch) channel = await this.client.resolveChannel(_ch);
response = await this.channels.setReadState(user.id, channel, author, state);
2021-06-20 16:43:05 +02:00
} else return `Could not resolve ${id} to a target.`;
2021-06-20 16:43:05 +02:00
}
if (!response) {
const { channel } = message;
const chCache = this.cache.channels;
2021-10-22 09:35:04 +02:00
const result = Object.entries(chCache).find(([ , val ]) => {
return val === channel.id;
});
2021-06-19 20:05:32 +02:00
if (!result) return {
error: true,
msg: `This doesn't seem to be a valid modmail channel. Cache might be out of sync. **[MISSING TARGET]**`
};
2021-06-19 20:05:32 +02:00
2021-10-22 09:35:04 +02:00
const [ userId ] = result;
user = await this.getUser(userId);
response = await this.channels.setReadState(userId, channel, author, state);
}
if (response.error) return response;
this.log({ author, action: `${author.tag} marked ${user.tag}'s thread as ${state}`, target: user });
return 'Done';
2021-06-19 15:06:20 +02:00
}
2021-10-22 09:35:04 +02:00
async sendReminder () {
2021-06-19 23:57:12 +02:00
await this.cache.verifyQueue();
2021-06-19 23:57:12 +02:00
const channel = this.reminderChannel;
const amount = this.queue.length;
if (!amount) {
2021-06-22 18:01:16 +02:00
if (this.lastReminder) {
2022-08-28 10:25:15 +02:00
await this.lastReminder.delete().catch(() => null);
2021-06-22 18:01:16 +02:00
this.lastReminder = null;
}
return;
}
2021-06-19 23:57:12 +02:00
const str = `${amount} modmail in queue.`;
2021-06-20 00:33:45 +02:00
this.client.logger.debug(`Sending modmail reminder, #mm: ${amount}`);
2021-06-19 23:57:12 +02:00
if (this.lastReminder) {
2021-11-28 18:31:17 +01:00
if (channel.lastMessage?.id === this.lastReminder?.id) return this.lastReminder.edit(str);
2021-06-19 23:57:12 +02:00
await this.lastReminder.delete();
2021-06-20 01:20:50 +02:00
}
this.lastReminder = await channel.send(str);
this.cache.misc.lastReminder = this.lastReminder.id;
2021-06-19 23:57:12 +02:00
}
2021-10-22 09:35:04 +02:00
async log ({ author, content, action, target }) {
const embed = {
author: {
name: action,
// eslint-disable-next-line camelcase
icon_url: author.displayAvatarURL({ dynamic: true })
},
description: content ? `\`\`\`${content}\`\`\`` : '',
color: this.mainServer.me.highestRoleColor
};
if (target) {
embed.footer = {
text: `Staff: ${author.id} | Target: ${target.id}`
};
}
2021-11-28 18:31:17 +01:00
this.logChannel.send({ embed }).catch((err) => this.client.logger.error(`Error during logging of modmail:\n${err.stack}`));
}
2021-10-22 09:35:04 +02:00
getCanned (name) {
2021-06-19 15:06:20 +02:00
return this.replies[name.toLowerCase()];
}
2021-10-22 09:35:04 +02:00
loadReplies () {
2021-06-19 15:06:20 +02:00
2021-06-19 20:05:32 +02:00
this.client.logger.info('Loading canned replies');
2021-06-19 15:06:20 +02:00
if (!fs.existsSync('./canned_replies.json')) return {};
return JSON.parse(fs.readFileSync('./canned_replies.json', { encoding: 'utf-8' }));
2021-06-18 15:41:57 +02:00
}
2021-10-22 09:35:04 +02:00
saveReplies () {
2021-06-19 20:05:32 +02:00
this.client.logger.info('Saving canned replies');
fs.writeFileSync('./canned_replies.json', JSON.stringify(this.replies));
}
2021-12-22 10:30:50 +01:00
disable (reason) {
this.disabled = true;
if (reason) this.disabledReason = reason;
else this.disabledReason = null;
this.cache.misc.disabled = true;
this.cache.misc.disabledReason = this.disabledReason;
this.cache.savePersistentCache();
}
enable () {
this.disabled = false;
this.cache.misc.disabled = false;
this.cache.savePersistentCache();
}
2021-06-18 15:41:57 +02:00
}
module.exports = Modmail;