diff --git a/CHANGELOG.md b/CHANGELOG.md index 5444712..a05f808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## v2.0.0 * Rewrote large parts of the code to be more modular and maintainable. There may be some new bugs because of this - please report them through GitHub issues if you encounter any! * Threads, logs, and snippets are now stored in an SQLite database. The bot will migrate old data on the first run. +* Small attachments (<2MB) from users are now relayed as Discord attachments in the modmail thread. Logs will have the link as usual. * Fixed system messages like pins in DMs being relayed to the thread * Fixed channels sometimes being created without a category diff --git a/db/migrations/20171223203915_create_tables.js b/db/migrations/20171223203915_create_tables.js index ce81c48..9c569dd 100644 --- a/db/migrations/20171223203915_create_tables.js +++ b/db/migrations/20171223203915_create_tables.js @@ -17,7 +17,7 @@ exports.up = async function(knex, Promise) { table.string('user_name', 128).notNullable(); table.text('body').notNullable(); table.integer('is_anonymous').unsigned().notNullable(); - table.string('original_message_id', 20).nullable().unique(); + table.string('dm_message_id', 20).nullable().unique(); table.dateTime('created_at').notNullable().index(); }); diff --git a/package-lock.json b/package-lock.json index 787a06f..3134ef0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "modmailbot", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/src/data/Thread.js b/src/data/Thread.js index a4b8cf8..a0feaa4 100644 --- a/src/data/Thread.js +++ b/src/data/Thread.js @@ -1,4 +1,6 @@ +const fs = require("fs"); const moment = require('moment'); +const {promisify} = require('util'); const bot = require('../bot'); const knex = require('../knex'); @@ -26,21 +28,11 @@ class Thread { /** * @param {Eris~Member} moderator * @param {String} text - * @param {Eris~Attachment[]} replyAttachments + * @param {Eris~MessageFile[]} replyAttachments * @param {Boolean} isAnonymous * @returns {Promise} */ async replyToUser(moderator, text, replyAttachments = [], isAnonymous = false) { - // Try to open a DM channel with the user - const dmChannel = await bot.getDMChannel(this.user_id); - if (! dmChannel) { - const channel = bot.getChannel(this.channel_id); - if (channel) { - channel.createMessage('Could not send reply: couldn\'t open DM channel with user'); - } - return; - } - // Username to reply with let modUsername, logModUsername; const mainRole = utils.getMainRole(moderator); @@ -55,29 +47,34 @@ class Thread { } // Build the reply message + const timestamp = utils.getTimestamp(); let dmContent = `**${modUsername}:** ${text}`; let threadContent = `**${logModUsername}:** ${text}`; let logContent = text; - let attachmentFile = null; - let attachmentUrl = null; + let files = []; // Prepare attachments, if any if (replyAttachments.length > 0) { - fs.readFile(attachments.getPath(replyAttachments[0].id), async (err, data) => { - attachmentFile = {file: data, name: replyAttachments[0].filename}; - attachmentUrl = await attachments.getUrl(replyAttachments[0].id, replyAttachments[0].filename); + for (const attachment of replyAttachments) { + files.push(await attachments.attachmentToFile(attachment)); + const url = await attachments.getUrl(attachment.id, attachment.filename); - threadContent += `\n\n**Attachment:** ${attachmentUrl}`; - logContent += `\n\n**Attachment:** ${attachmentUrl}`; - }); + logContent += `\n\n**Attachment:** ${url}`; + } } // Send the reply DM - dmChannel.createMessage(dmContent, attachmentFile); + let dmMessage; + try { + dmMessage = await this.postToUser(dmContent, files); + } catch (e) { + await this.postSystemMessage(`Error while replying to user: ${e.message}`); + return; + } // Send the reply to the modmail thread - const originalMessage = await this.postToThreadChannel(threadContent); + await this.postToThreadChannel(threadContent, files); // Add the message to the database await this.addThreadMessageToDB({ @@ -86,60 +83,84 @@ class Thread { user_name: logModUsername, body: logContent, is_anonymous: (isAnonymous ? 1 : 0), - original_message_id: originalMessage.id + dm_message_id: dmMessage.id }); } /** - * @param {Eris.Message} msg + * @param {Eris~Message} msg * @returns {Promise} */ async receiveUserReply(msg) { - const timestamp = utils.getTimestamp(); - let content = msg.content; if (msg.content.trim() === '' && msg.embeds.length) { content = ''; } - let threadContent = `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${content}`; + let threadContent = `**${msg.author.username}#${msg.author.discriminator}:** ${content}`; let logContent = msg.content; - let finalThreadContent; - let attachmentSavePromise; + let attachmentFiles = []; - if (msg.attachments.length) { - attachmentSavePromise = attachments.saveAttachmentsInMessage(msg); - const formattedAttachments = await Promise.all(msg.attachments.map(utils.formatAttachment)); - const attachmentMsg = `\n\n` + formattedAttachments.reduce((str, formatted) => str + `\n\n${formatted}`); + for (const attachment of msg.attachments) { + await attachments.saveAttachment(attachment); - finalThreadContent = threadContent + attachmentMsg; - threadContent += '\n\n*Attachments pending...*'; - logContent += attachmentMsg; + // Forward small attachments (<2MB) as attachments, just link to larger ones + const formatted = '\n\n' + await utils.formatAttachment(attachment); + logContent += formatted; // Logs always contain the link + + if (attachment.size > 1024 * 1024 * 2) { + threadContent += formatted; + } else { + const file = await attachments.attachmentToFile(attachment); + attachmentFiles.push(file); + } } - const createdMessage = await this.postToThreadChannel(threadContent); + 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, - original_message_id: msg.id + dm_message_id: msg.id }); - - if (msg.attachments.length) { - await attachmentSavePromise; - await createdMessage.edit(finalThreadContent); - } } /** * @param {String} text - * @param {Eris.MessageFile} file - * @returns {Promise} + * @param {Eris~MessageFile|Eris~MessageFile[]} file + * @returns {Promise} + * @throws Error + */ + async postToUser(text, file = null) { + // Try to open a DM channel with the user + const dmChannel = await bot.getDMChannel(this.user_id); + 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 + return dmChannel.createMessage(text, file); + } + + /** + * @param {String} text + * @param {Eris~MessageFile|Eris~MessageFile[]} file + * @returns {Promise} */ async postToThreadChannel(text, file = null) { - return bot.createMessage(this.channel_id, text, file); + try { + return await bot.createMessage(this.channel_id, text, file); + } catch (e) { + // Channel not found + if (e.code === 10003) { + console.log(`[INFO] Auto-closing thread with ${this.user_name} because the channel no longer exists`); + this.close(true); + } else { + throw e; + } + } } /** @@ -154,7 +175,7 @@ class Thread { user_name: '', body: text, is_anonymous: 0, - original_message_id: msg.id + dm_message_id: msg.id }); } @@ -177,7 +198,7 @@ class Thread { user_name: `${msg.author.username}#${msg.author.discriminator}`, body: msg.content, is_anonymous: 0, - original_message_id: msg.id + dm_message_id: msg.id }); } @@ -188,7 +209,7 @@ class Thread { async updateChatMessage(msg) { await knex('thread_messages') .where('thread_id', this.id) - .where('original_message_id', msg.id) + .where('dm_message_id', msg.id) .update({ content: msg.content }); @@ -201,7 +222,7 @@ class Thread { async deleteChatMessage(messageId) { await knex('thread_messages') .where('thread_id', this.id) - .where('original_message_id', messageId) + .where('dm_message_id', messageId) .delete(); } @@ -234,9 +255,11 @@ class Thread { /** * @returns {Promise} */ - async close() { - console.log(`Closing thread ${this.id}`); - await this.postToThreadChannel('Closing thread...'); + async close(silent = false) { + if (! silent) { + console.log(`Closing thread ${this.id}`); + await this.postToThreadChannel('Closing thread...'); + } // Update DB status await knex('threads') @@ -246,9 +269,9 @@ class Thread { }); // Delete channel - console.log(`Deleting channel ${this.channel_id}`); const channel = bot.getChannel(this.channel_id); if (channel) { + console.log(`Deleting channel ${this.channel_id}`); await channel.delete('Thread closed'); } } diff --git a/src/data/ThreadMessage.js b/src/data/ThreadMessage.js index e647988..06004f4 100644 --- a/src/data/ThreadMessage.js +++ b/src/data/ThreadMessage.js @@ -6,7 +6,7 @@ * @property {String} user_name * @property {String} body * @property {Number} is_anonymous - * @property {Number} original_message_id + * @property {Number} dm_message_id * @property {String} created_at */ class ThreadMessage { diff --git a/src/data/attachments.js b/src/data/attachments.js index 193b108..5909ff7 100644 --- a/src/data/attachments.js +++ b/src/data/attachments.js @@ -2,11 +2,17 @@ const Eris = require('eris'); const fs = require('fs'); const https = require('https'); const config = require('../config'); +const {promisify} = require('util'); const getUtils = () => require('../utils'); +const access = promisify(fs.access); +const readFile = promisify(fs.readFile); + const attachmentDir = config.attachmentDir || `${__dirname}/../../attachments`; +const attachmentSavePromises = {}; + /** * Returns the filesystem path for the given attachment id * @param {String} attachmentId @@ -22,7 +28,30 @@ function getPath(attachmentId) { * @param {Number=0} tries * @returns {Promise} */ -function saveAttachment(attachment, tries = 0) { +async function saveAttachment(attachment) { + if (attachmentSavePromises[attachment.id]) { + return attachmentSavePromises[attachment.id]; + } + + const filepath = getPath(attachment.id); + try { + // If the file already exists, resolve immediately + await access(filepath); + return; + } catch (e) {} + + attachmentSavePromises[attachment.id] = saveAttachmentInner(attachment); + attachmentSavePromises[attachment.id] + .then(() => { + delete attachmentSavePromises[attachment.id]; + }, () => { + delete attachmentSavePromises[attachment.id]; + }); + + return attachmentSavePromises[attachment.id]; +} + +function saveAttachmentInner(attachment, tries = 0) { return new Promise((resolve, reject) => { if (tries > 3) { console.error('Attachment download failed after 3 tries:', attachment); @@ -42,7 +71,7 @@ function saveAttachment(attachment, tries = 0) { }).on('error', (err) => { fs.unlink(filepath); console.error('Error downloading attachment, retrying'); - resolve(saveAttachment(attachment)); + resolve(saveAttachmentInner(attachment, tries++)); }); }); } @@ -68,9 +97,16 @@ function getUrl(attachmentId, desiredName = null) { return getUtils().getSelfUrl(`attachments/${attachmentId}/${desiredName}`); } +async function attachmentToFile(attachment) { + await saveAttachment(attachment); + const data = await readFile(getPath(attachment.id)); + return {file: data, name: attachment.filename}; +} + module.exports = { getPath, saveAttachment, saveAttachmentsInMessage, getUrl, + attachmentToFile }; diff --git a/src/data/threads.js b/src/data/threads.js index 3ec839a..9e4d513 100644 --- a/src/data/threads.js +++ b/src/data/threads.js @@ -12,6 +12,10 @@ const utils = require('../utils'); const Thread = require('./Thread'); const {THREAD_STATUS} = require('./constants'); +/** + * @param {String} id + * @returns {Promise} + */ async function findById(id) { const thread = await knex('threads') .where('id', id) @@ -79,6 +83,11 @@ async function createNewThreadForUser(user) { const logUrl = await newThread.getLogUrl(); await newThread.postNonLogMessage(`Log URL: <${logUrl}>`); + // Send auto-reply to the user + if (config.responseMessage) { + newThread.postToUser(config.responseMessage); + } + // Post some info to the beginning of the new thread const mainGuild = utils.getMainGuild(); const member = (mainGuild ? mainGuild.members.get(user.id) : null); diff --git a/src/main.js b/src/main.js index 3dcafe4..b65fac4 100644 --- a/src/main.js +++ b/src/main.js @@ -12,6 +12,7 @@ const threads = require('./data/threads'); const snippets = require('./plugins/snippets'); const webserver = require('./plugins/webserver'); const greeting = require('./plugins/greeting'); +const attachments = require("./data/attachments"); const messageQueue = new Queue(); @@ -38,6 +39,7 @@ bot.on('messageCreate', async msg => { if (config.alwaysReply) { // AUTO-REPLY: If config.alwaysReply is enabled, send all chat messages in thread channels as replies + if (msg.attachments.length) await attachments.saveAttachmentsInMessage(msg); await thread.replyToUser(msg.member, msg.content.trim(), msg.attachments, config.alwaysReplyAnon || false); msg.delete(); } else { @@ -139,6 +141,7 @@ addInboxServerCommand('reply', async (msg, args, thread) => { if (! thread) return; const text = args.join(' ').trim(); + if (msg.attachments.length) await attachments.saveAttachmentsInMessage(msg); await thread.replyToUser(msg.member, text, msg.attachments, false); msg.delete(); }); @@ -150,6 +153,7 @@ addInboxServerCommand('anonreply', async (msg, args, thread) => { if (! thread) return; const text = args.join(' ').trim(); + if (msg.attachments.length) await attachments.saveAttachmentsInMessage(msg); await thread.replyToUser(msg.member, text, msg.attachments, true); msg.delete(); });