aboutsummaryrefslogtreecommitdiff
path: root/src/data/Thread.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/data/Thread.js')
-rw-r--r--src/data/Thread.js468
1 files changed, 468 insertions, 0 deletions
diff --git a/src/data/Thread.js b/src/data/Thread.js
new file mode 100644
index 0000000..23e9e4a
--- /dev/null
+++ b/src/data/Thread.js
@@ -0,0 +1,468 @@
+const moment = require('moment');
+
+const bot = require('../bot');
+const knex = require('../knex');
+const utils = require('../utils');
+const config = require('../config');
+const attachments = require('./attachments');
+
+const ThreadMessage = require('./ThreadMessage');
+
+const {THREAD_MESSAGE_TYPE, THREAD_STATUS} = require('./constants');
+
+/**
+ * @property {String} id
+ * @property {Number} status
+ * @property {String} user_id
+ * @property {String} user_name
+ * @property {String} channel_id
+ * @property {String} scheduled_close_at
+ * @property {String} scheduled_close_id
+ * @property {String} scheduled_close_name
+ * @property {Number} scheduled_close_silent
+ * @property {String} alert_id
+ * @property {String} created_at
+ */
+class Thread {
+ constructor(props) {
+ utils.setDataModelProps(this, props);
+ }
+
+ /**
+ * @param {Eris~Member} moderator
+ * @param {String} text
+ * @param {Eris~MessageFile[]} replyAttachments
+ * @param {Boolean} isAnonymous
+ * @returns {Promise<boolean>} Whether we were able to send the reply
+ */
+ async replyToUser(moderator, text, replyAttachments = [], isAnonymous = false) {
+ // Username to reply with
+ let modUsername, logModUsername;
+ const mainRole = utils.getMainRole(moderator);
+
+ if (isAnonymous) {
+ modUsername = (mainRole ? mainRole.name : 'Moderator');
+ logModUsername = `(Anonymous) (${moderator.user.username}) ${mainRole ? mainRole.name : 'Moderator'}`;
+ } else {
+ const name = (config.useNicknames ? moderator.nick || moderator.user.username : moderator.user.username);
+ modUsername = (mainRole ? `(${mainRole.name}) ${name}` : name);
+ logModUsername = modUsername;
+ }
+
+ // Build the reply message
+ let dmContent = `**${modUsername}:** ${text}`;
+ let threadContent = `**${logModUsername}:** ${text}`;
+ let logContent = text;
+
+ if (config.threadTimestamps) {
+ const timestamp = utils.getTimestamp();
+ threadContent = `[${timestamp}] » ${threadContent}`;
+ }
+
+ // Prepare attachments, if any
+ let files = [];
+
+ if (replyAttachments.length > 0) {
+ for (const attachment of replyAttachments) {
+ let savedAttachment;
+
+ await Promise.all([
+ attachments.attachmentToDiscordFileObject(attachment).then(file => {
+ files.push(file);
+ }),
+ attachments.saveAttachment(attachment).then(result => {
+ savedAttachment = result;
+ })
+ ]);
+
+ logContent += `\n\n**Attachment:** ${savedAttachment.url}`;
+ }
+ }
+
+ // Send the reply DM
+ let dmMessage;
+ try {
+ dmMessage = await this.postToUser(dmContent, files);
+ } catch (e) {
+ await this.addThreadMessageToDB({
+ message_type: THREAD_MESSAGE_TYPE.COMMAND,
+ user_id: moderator.id,
+ user_name: logModUsername,
+ body: logContent
+ });
+
+ await this.postSystemMessage(`Error while replying to user: ${e.message}`);
+
+ return false;
+ }
+
+ // Send the reply to the modmail thread
+ await this.postToThreadChannel(threadContent, files);
+
+ // Add the message to the database
+ await this.addThreadMessageToDB({
+ message_type: THREAD_MESSAGE_TYPE.TO_USER,
+ user_id: moderator.id,
+ user_name: logModUsername,
+ body: logContent,
+ is_anonymous: (isAnonymous ? 1 : 0),
+ dm_message_id: dmMessage.id
+ });
+
+ if (this.scheduled_close_at) {
+ await this.cancelScheduledClose();
+ await this.postSystemMessage(`Cancelling scheduled closing of this thread due to new reply`);
+ }
+
+ return true;
+ }
+
+ /**
+ * @param {Eris~Message} msg
+ * @returns {Promise<void>}
+ */
+ async receiveUserReply(msg) {
+ let content = msg.content;
+ if (msg.content.trim() === '' && msg.embeds.length) {
+ content = '<message contains embeds>';
+ }
+
+ let threadContent = `**${msg.author.username}#${msg.author.discriminator}:** ${content}`;
+ let logContent = msg.content;
+
+ if (config.threadTimestamps) {
+ const timestamp = utils.getTimestamp(msg.timestamp, 'x');
+ threadContent = `[${timestamp}] « ${threadContent}`;
+ }
+
+ // Prepare attachments, if any
+ let attachmentFiles = [];
+
+ for (const attachment of msg.attachments) {
+ const savedAttachment = await attachments.saveAttachment(attachment);
+
+ // Forward small attachments (<2MB) as attachments, just link to larger ones
+ const formatted = '\n\n' + await utils.formatAttachment(attachment, savedAttachment.url);
+ logContent += formatted; // Logs always contain the link
+
+ if (config.relaySmallAttachmentsAsAttachments && attachment.size <= 1024 * 1024 * 2) {
+ const file = await attachments.attachmentToDiscordFileObject(attachment);
+ attachmentFiles.push(file);
+ } else {
+ threadContent += formatted;
+ }
+ }
+
+ await this.postToThreadChannel(threadContent, attachmentFiles);
+ await this.addThreadMessageToDB({
+ message_type: THREAD_MESSAGE_TYPE.FROM_USER,
+ user_id: this.user_id,
+ user_name: `${msg.author.username}#${msg.author.discriminator}`,
+ body: logContent,
+ is_anonymous: 0,
+ dm_message_id: msg.id
+ });
+
+ if (this.scheduled_close_at) {
+ await this.cancelScheduledClose();
+ await this.postSystemMessage(`<@!${this.scheduled_close_id}> Thread that was scheduled to be closed got a new reply. Cancelling.`);
+ }
+
+ if (this.alert_id) {
+ await this.setAlert(null);
+ await this.postSystemMessage(`<@!${this.alert_id}> New message from ${this.user_name}`);
+ }
+ }
+
+ /**
+ * @returns {Promise<PrivateChannel>}
+ */
+ getDMChannel() {
+ return bot.getDMChannel(this.user_id);
+ }
+
+ /**
+ * @param {String} text
+ * @param {Eris~MessageFile|Eris~MessageFile[]} file
+ * @returns {Promise<Eris~Message>}
+ * @throws Error
+ */
+ async postToUser(text, file = null) {
+ // Try to open a DM channel with the user
+ const dmChannel = await this.getDMChannel();
+ if (! dmChannel) {
+ throw new Error('Could not open DMs with the user. They may have blocked the bot or set their privacy settings higher.');
+ }
+
+ // Send the DM
+ const chunks = utils.chunk(text, 2000);
+ const messages = await Promise.all(chunks.map((chunk, i) => {
+ return dmChannel.createMessage(
+ chunk,
+ (i === chunks.length - 1 ? file : undefined) // Only send the file with the last message
+ );
+ }));
+ return messages[0];
+ }
+
+ /**
+ * @returns {Promise<Eris~Message>}
+ */
+ async postToThreadChannel(...args) {
+ try {
+ if (typeof args[0] === 'string') {
+ const chunks = utils.chunk(args[0], 2000);
+ const messages = await Promise.all(chunks.map((chunk, i) => {
+ const rest = (i === chunks.length - 1 ? args.slice(1) : []); // Only send the rest of the args (files, embeds) with the last message
+ return bot.createMessage(this.channel_id, chunk, ...rest);
+ }));
+ return messages[0];
+ } else {
+ return bot.createMessage(this.channel_id, ...args);
+ }
+ } catch (e) {
+ // Channel not found
+ if (e.code === 10003) {
+ console.log(`[INFO] Failed to send message to thread channel for ${this.user_name} because the channel no longer exists. Auto-closing the thread.`);
+ this.close(true);
+ } else {
+ throw e;
+ }
+ }
+ }
+
+ /**
+ * @param {String} text
+ * @param {*} args
+ * @returns {Promise<void>}
+ */
+ async postSystemMessage(text, ...args) {
+ const msg = await this.postToThreadChannel(text, ...args);
+ await this.addThreadMessageToDB({
+ message_type: THREAD_MESSAGE_TYPE.SYSTEM,
+ user_id: null,
+ user_name: '',
+ body: typeof text === 'string' ? text : text.content,
+ is_anonymous: 0,
+ dm_message_id: msg.id
+ });
+ }
+
+ /**
+ * @param {*} args
+ * @returns {Promise<void>}
+ */
+ async postNonLogMessage(...args) {
+ await this.postToThreadChannel(...args);
+ }
+
+ /**
+ * @param {Eris.Message} msg
+ * @returns {Promise<void>}
+ */
+ async saveChatMessage(msg) {
+ return this.addThreadMessageToDB({
+ message_type: THREAD_MESSAGE_TYPE.CHAT,
+ user_id: msg.author.id,
+ user_name: `${msg.author.username}#${msg.author.discriminator}`,
+ body: msg.content,
+ is_anonymous: 0,
+ dm_message_id: msg.id
+ });
+ }
+
+ async saveCommandMessage(msg) {
+ return this.addThreadMessageToDB({
+ message_type: THREAD_MESSAGE_TYPE.COMMAND,
+ user_id: msg.author.id,
+ user_name: `${msg.author.username}#${msg.author.discriminator}`,
+ body: msg.content,
+ is_anonymous: 0,
+ dm_message_id: msg.id
+ });
+ }
+
+ /**
+ * @param {Eris.Message} msg
+ * @returns {Promise<void>}
+ */
+ async updateChatMessage(msg) {
+ await knex('thread_messages')
+ .where('thread_id', this.id)
+ .where('dm_message_id', msg.id)
+ .update({
+ body: msg.content
+ });
+ }
+
+ /**
+ * @param {String} messageId
+ * @returns {Promise<void>}
+ */
+ async deleteChatMessage(messageId) {
+ await knex('thread_messages')
+ .where('thread_id', this.id)
+ .where('dm_message_id', messageId)
+ .delete();
+ }
+
+ /**
+ * @param {Object} data
+ * @returns {Promise<void>}
+ */
+ async addThreadMessageToDB(data) {
+ await knex('thread_messages').insert({
+ thread_id: this.id,
+ created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss'),
+ is_anonymous: 0,
+ ...data
+ });
+ }
+
+ /**
+ * @returns {Promise<ThreadMessage[]>}
+ */
+ async getThreadMessages() {
+ const threadMessages = await knex('thread_messages')
+ .where('thread_id', this.id)
+ .orderBy('created_at', 'ASC')
+ .orderBy('id', 'ASC')
+ .select();
+
+ return threadMessages.map(row => new ThreadMessage(row));
+ }
+
+ /**
+ * @returns {Promise<void>}
+ */
+ async close(suppressSystemMessage = false, silent = false) {
+ if (! suppressSystemMessage) {
+ console.log(`Closing thread ${this.id}`);
+
+ if (silent) {
+ await this.postSystemMessage('Closing thread silently...');
+ } else {
+ await this.postSystemMessage('Closing thread...');
+ }
+ }
+
+ // Update DB status
+ await knex('threads')
+ .where('id', this.id)
+ .update({
+ status: THREAD_STATUS.CLOSED
+ });
+
+ // Delete channel
+ const channel = bot.getChannel(this.channel_id);
+ if (channel) {
+ console.log(`Deleting channel ${this.channel_id}`);
+ await channel.delete('Thread closed');
+ }
+ }
+
+ /**
+ * @param {String} time
+ * @param {Eris~User} user
+ * @param {Number} silent
+ * @returns {Promise<void>}
+ */
+ async scheduleClose(time, user, silent) {
+ await knex('threads')
+ .where('id', this.id)
+ .update({
+ scheduled_close_at: time,
+ scheduled_close_id: user.id,
+ scheduled_close_name: user.username,
+ scheduled_close_silent: silent
+ });
+ }
+
+ /**
+ * @returns {Promise<void>}
+ */
+ async cancelScheduledClose() {
+ await knex('threads')
+ .where('id', this.id)
+ .update({
+ scheduled_close_at: null,
+ scheduled_close_id: null,
+ scheduled_close_name: null,
+ scheduled_close_silent: null
+ });
+ }
+
+ /**
+ * @returns {Promise<void>}
+ */
+ async suspend() {
+ await knex('threads')
+ .where('id', this.id)
+ .update({
+ status: THREAD_STATUS.SUSPENDED,
+ scheduled_suspend_at: null,
+ scheduled_suspend_id: null,
+ scheduled_suspend_name: null
+ });
+ }
+
+ /**
+ * @returns {Promise<void>}
+ */
+ async unsuspend() {
+ await knex('threads')
+ .where('id', this.id)
+ .update({
+ status: THREAD_STATUS.OPEN
+ });
+ }
+
+ /**
+ * @param {String} time
+ * @param {Eris~User} user
+ * @returns {Promise<void>}
+ */
+ async scheduleSuspend(time, user) {
+ await knex('threads')
+ .where('id', this.id)
+ .update({
+ scheduled_suspend_at: time,
+ scheduled_suspend_id: user.id,
+ scheduled_suspend_name: user.username
+ });
+ }
+
+ /**
+ * @returns {Promise<void>}
+ */
+ async cancelScheduledSuspend() {
+ await knex('threads')
+ .where('id', this.id)
+ .update({
+ scheduled_suspend_at: null,
+ scheduled_suspend_id: null,
+ scheduled_suspend_name: null
+ });
+ }
+
+ /**
+ * @param {String} userId
+ * @returns {Promise<void>}
+ */
+ async setAlert(userId) {
+ await knex('threads')
+ .where('id', this.id)
+ .update({
+ alert_id: userId
+ });
+ }
+
+ /**
+ * @returns {Promise<String>}
+ */
+ getLogUrl() {
+ return utils.getSelfUrl(`logs/${this.id}`);
+ }
+}
+
+module.exports = Thread;