Relay small attachments as attachments. Auto-close threads if the channel no longer exists when receiving a reply.
parent
ad7aa66c99
commit
32c22f4d46
|
@ -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
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "modmailbot",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
|
|
@ -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() {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue