Relay small attachments as attachments. Auto-close threads if the channel no longer exists when receiving a reply.

master
Dragory 2018-02-14 08:53:34 +02:00
parent ad7aa66c99
commit 32c22f4d46
8 changed files with 131 additions and 58 deletions

View File

@ -3,6 +3,7 @@
## v2.0.0 ## 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! * 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. * 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 system messages like pins in DMs being relayed to the thread
* Fixed channels sometimes being created without a category * Fixed channels sometimes being created without a category

View File

@ -17,7 +17,7 @@ exports.up = async function(knex, Promise) {
table.string('user_name', 128).notNullable(); table.string('user_name', 128).notNullable();
table.text('body').notNullable(); table.text('body').notNullable();
table.integer('is_anonymous').unsigned().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(); table.dateTime('created_at').notNullable().index();
}); });

2
package-lock.json generated
View File

@ -1,6 +1,6 @@
{ {
"name": "modmailbot", "name": "modmailbot",
"version": "1.0.0", "version": "2.0.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {

View File

@ -1,4 +1,6 @@
const fs = require("fs");
const moment = require('moment'); const moment = require('moment');
const {promisify} = require('util');
const bot = require('../bot'); const bot = require('../bot');
const knex = require('../knex'); const knex = require('../knex');
@ -26,21 +28,11 @@ class Thread {
/** /**
* @param {Eris~Member} moderator * @param {Eris~Member} moderator
* @param {String} text * @param {String} text
* @param {Eris~Attachment[]} replyAttachments * @param {Eris~MessageFile[]} replyAttachments
* @param {Boolean} isAnonymous * @param {Boolean} isAnonymous
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async replyToUser(moderator, text, replyAttachments = [], isAnonymous = false) { 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 // Username to reply with
let modUsername, logModUsername; let modUsername, logModUsername;
const mainRole = utils.getMainRole(moderator); const mainRole = utils.getMainRole(moderator);
@ -55,29 +47,34 @@ class Thread {
} }
// Build the reply message // Build the reply message
const timestamp = utils.getTimestamp();
let dmContent = `**${modUsername}:** ${text}`; let dmContent = `**${modUsername}:** ${text}`;
let threadContent = `**${logModUsername}:** ${text}`; let threadContent = `**${logModUsername}:** ${text}`;
let logContent = text; let logContent = text;
let attachmentFile = null; let files = [];
let attachmentUrl = null;
// Prepare attachments, if any // Prepare attachments, if any
if (replyAttachments.length > 0) { if (replyAttachments.length > 0) {
fs.readFile(attachments.getPath(replyAttachments[0].id), async (err, data) => { for (const attachment of replyAttachments) {
attachmentFile = {file: data, name: replyAttachments[0].filename}; files.push(await attachments.attachmentToFile(attachment));
attachmentUrl = await attachments.getUrl(replyAttachments[0].id, replyAttachments[0].filename); const url = await attachments.getUrl(attachment.id, attachment.filename);
threadContent += `\n\n**Attachment:** ${attachmentUrl}`; logContent += `\n\n**Attachment:** ${url}`;
logContent += `\n\n**Attachment:** ${attachmentUrl}`; }
});
} }
// Send the reply DM // 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 // Send the reply to the modmail thread
const originalMessage = await this.postToThreadChannel(threadContent); await this.postToThreadChannel(threadContent, files);
// Add the message to the database // Add the message to the database
await this.addThreadMessageToDB({ await this.addThreadMessageToDB({
@ -86,60 +83,84 @@ class Thread {
user_name: logModUsername, user_name: logModUsername,
body: logContent, body: logContent,
is_anonymous: (isAnonymous ? 1 : 0), 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<void>} * @returns {Promise<void>}
*/ */
async receiveUserReply(msg) { async receiveUserReply(msg) {
const timestamp = utils.getTimestamp();
let content = msg.content; let content = msg.content;
if (msg.content.trim() === '' && msg.embeds.length) { if (msg.content.trim() === '' && msg.embeds.length) {
content = '<message contains embeds>'; content = '<message contains embeds>';
} }
let threadContent = `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${content}`; let threadContent = `**${msg.author.username}#${msg.author.discriminator}:** ${content}`;
let logContent = msg.content; let logContent = msg.content;
let finalThreadContent; let attachmentFiles = [];
let attachmentSavePromise;
if (msg.attachments.length) { for (const attachment of msg.attachments) {
attachmentSavePromise = attachments.saveAttachmentsInMessage(msg); await attachments.saveAttachment(attachment);
const formattedAttachments = await Promise.all(msg.attachments.map(utils.formatAttachment));
const attachmentMsg = `\n\n` + formattedAttachments.reduce((str, formatted) => str + `\n\n${formatted}`);
finalThreadContent = threadContent + attachmentMsg; // Forward small attachments (<2MB) as attachments, just link to larger ones
threadContent += '\n\n*Attachments pending...*'; const formatted = '\n\n' + await utils.formatAttachment(attachment);
logContent += attachmentMsg; 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({ await this.addThreadMessageToDB({
message_type: THREAD_MESSAGE_TYPE.FROM_USER, message_type: THREAD_MESSAGE_TYPE.FROM_USER,
user_id: this.user_id, user_id: this.user_id,
user_name: `${msg.author.username}#${msg.author.discriminator}`, user_name: `${msg.author.username}#${msg.author.discriminator}`,
body: logContent, body: logContent,
is_anonymous: 0, 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 {String} text
* @param {Eris.MessageFile} file * @param {Eris~MessageFile|Eris~MessageFile[]} file
* @returns {Promise<Eris.Message>} * @returns {Promise<Eris~Message>}
* @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<Eris~Message>}
*/ */
async postToThreadChannel(text, file = null) { 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: '', user_name: '',
body: text, body: text,
is_anonymous: 0, 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}`, user_name: `${msg.author.username}#${msg.author.discriminator}`,
body: msg.content, body: msg.content,
is_anonymous: 0, is_anonymous: 0,
original_message_id: msg.id dm_message_id: msg.id
}); });
} }
@ -188,7 +209,7 @@ class Thread {
async updateChatMessage(msg) { async updateChatMessage(msg) {
await knex('thread_messages') await knex('thread_messages')
.where('thread_id', this.id) .where('thread_id', this.id)
.where('original_message_id', msg.id) .where('dm_message_id', msg.id)
.update({ .update({
content: msg.content content: msg.content
}); });
@ -201,7 +222,7 @@ class Thread {
async deleteChatMessage(messageId) { async deleteChatMessage(messageId) {
await knex('thread_messages') await knex('thread_messages')
.where('thread_id', this.id) .where('thread_id', this.id)
.where('original_message_id', messageId) .where('dm_message_id', messageId)
.delete(); .delete();
} }
@ -234,9 +255,11 @@ class Thread {
/** /**
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async close() { async close(silent = false) {
if (! silent) {
console.log(`Closing thread ${this.id}`); console.log(`Closing thread ${this.id}`);
await this.postToThreadChannel('Closing thread...'); await this.postToThreadChannel('Closing thread...');
}
// Update DB status // Update DB status
await knex('threads') await knex('threads')
@ -246,9 +269,9 @@ class Thread {
}); });
// Delete channel // Delete channel
console.log(`Deleting channel ${this.channel_id}`);
const channel = bot.getChannel(this.channel_id); const channel = bot.getChannel(this.channel_id);
if (channel) { if (channel) {
console.log(`Deleting channel ${this.channel_id}`);
await channel.delete('Thread closed'); await channel.delete('Thread closed');
} }
} }

View File

@ -6,7 +6,7 @@
* @property {String} user_name * @property {String} user_name
* @property {String} body * @property {String} body
* @property {Number} is_anonymous * @property {Number} is_anonymous
* @property {Number} original_message_id * @property {Number} dm_message_id
* @property {String} created_at * @property {String} created_at
*/ */
class ThreadMessage { class ThreadMessage {

View File

@ -2,11 +2,17 @@ const Eris = require('eris');
const fs = require('fs'); const fs = require('fs');
const https = require('https'); const https = require('https');
const config = require('../config'); const config = require('../config');
const {promisify} = require('util');
const getUtils = () => require('../utils'); const getUtils = () => require('../utils');
const access = promisify(fs.access);
const readFile = promisify(fs.readFile);
const attachmentDir = config.attachmentDir || `${__dirname}/../../attachments`; const attachmentDir = config.attachmentDir || `${__dirname}/../../attachments`;
const attachmentSavePromises = {};
/** /**
* Returns the filesystem path for the given attachment id * Returns the filesystem path for the given attachment id
* @param {String} attachmentId * @param {String} attachmentId
@ -22,7 +28,30 @@ function getPath(attachmentId) {
* @param {Number=0} tries * @param {Number=0} tries
* @returns {Promise} * @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) => { return new Promise((resolve, reject) => {
if (tries > 3) { if (tries > 3) {
console.error('Attachment download failed after 3 tries:', attachment); console.error('Attachment download failed after 3 tries:', attachment);
@ -42,7 +71,7 @@ function saveAttachment(attachment, tries = 0) {
}).on('error', (err) => { }).on('error', (err) => {
fs.unlink(filepath); fs.unlink(filepath);
console.error('Error downloading attachment, retrying'); 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}`); 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 = { module.exports = {
getPath, getPath,
saveAttachment, saveAttachment,
saveAttachmentsInMessage, saveAttachmentsInMessage,
getUrl, getUrl,
attachmentToFile
}; };

View File

@ -12,6 +12,10 @@ const utils = require('../utils');
const Thread = require('./Thread'); const Thread = require('./Thread');
const {THREAD_STATUS} = require('./constants'); const {THREAD_STATUS} = require('./constants');
/**
* @param {String} id
* @returns {Promise<Thread>}
*/
async function findById(id) { async function findById(id) {
const thread = await knex('threads') const thread = await knex('threads')
.where('id', id) .where('id', id)
@ -79,6 +83,11 @@ async function createNewThreadForUser(user) {
const logUrl = await newThread.getLogUrl(); const logUrl = await newThread.getLogUrl();
await newThread.postNonLogMessage(`Log URL: <${logUrl}>`); 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 // Post some info to the beginning of the new thread
const mainGuild = utils.getMainGuild(); const mainGuild = utils.getMainGuild();
const member = (mainGuild ? mainGuild.members.get(user.id) : null); const member = (mainGuild ? mainGuild.members.get(user.id) : null);

View File

@ -12,6 +12,7 @@ const threads = require('./data/threads');
const snippets = require('./plugins/snippets'); const snippets = require('./plugins/snippets');
const webserver = require('./plugins/webserver'); const webserver = require('./plugins/webserver');
const greeting = require('./plugins/greeting'); const greeting = require('./plugins/greeting');
const attachments = require("./data/attachments");
const messageQueue = new Queue(); const messageQueue = new Queue();
@ -38,6 +39,7 @@ bot.on('messageCreate', async msg => {
if (config.alwaysReply) { if (config.alwaysReply) {
// AUTO-REPLY: If config.alwaysReply is enabled, send all chat messages in thread channels as replies // 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); await thread.replyToUser(msg.member, msg.content.trim(), msg.attachments, config.alwaysReplyAnon || false);
msg.delete(); msg.delete();
} else { } else {
@ -139,6 +141,7 @@ addInboxServerCommand('reply', async (msg, args, thread) => {
if (! thread) return; if (! thread) return;
const text = args.join(' ').trim(); const text = args.join(' ').trim();
if (msg.attachments.length) await attachments.saveAttachmentsInMessage(msg);
await thread.replyToUser(msg.member, text, msg.attachments, false); await thread.replyToUser(msg.member, text, msg.attachments, false);
msg.delete(); msg.delete();
}); });
@ -150,6 +153,7 @@ addInboxServerCommand('anonreply', async (msg, args, thread) => {
if (! thread) return; if (! thread) return;
const text = args.join(' ').trim(); const text = args.join(' ').trim();
if (msg.attachments.length) await attachments.saveAttachmentsInMessage(msg);
await thread.replyToUser(msg.member, text, msg.attachments, true); await thread.replyToUser(msg.member, text, msg.attachments, true);
msg.delete(); msg.delete();
}); });