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
* 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

View File

@ -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();
});

2
package-lock.json generated
View File

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

View File

@ -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<void>}
*/
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<void>}
*/
async receiveUserReply(msg) {
const timestamp = utils.getTimestamp();
let content = msg.content;
if (msg.content.trim() === '' && msg.embeds.length) {
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 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<Eris.Message>}
* @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 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) {
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<void>}
*/
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');
}
}

View File

@ -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 {

View File

@ -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
};

View File

@ -12,6 +12,10 @@ const utils = require('../utils');
const Thread = require('./Thread');
const {THREAD_STATUS} = require('./constants');
/**
* @param {String} id
* @returns {Promise<Thread>}
*/
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);

View File

@ -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();
});