diff --git a/.nvmrc b/.nvmrc index dc3829f..18bb418 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -6.9.1 +7.5.0 diff --git a/src/attachments.js b/src/attachments.js index 82ba333..fbad2a4 100644 --- a/src/attachments.js +++ b/src/attachments.js @@ -1,14 +1,26 @@ +const Eris = require('eris'); const fs = require('fs'); const https = require('https'); const config = require('../config'); const utils = require('./utils'); -const attachmentDir = config.attachmentDir || `${__dirname}/attachments`; +const attachmentDir = config.attachmentDir || `${__dirname}/../attachments`; -function getAttachmentPath(id) { - return `${attachmentDir}/${id}`; +/** + * Returns the filesystem path for the given attachment id + * @param {String} attachmentId + * @returns {String} + */ +function getPath(attachmentId) { + return `${attachmentDir}/${attachmentId}`; } +/** + * Attempts to download and save the given attachement + * @param {Object} attachment + * @param {Number=0} tries + * @returns {Promise} + */ function saveAttachment(attachment, tries = 0) { return new Promise((resolve, reject) => { if (tries > 3) { @@ -17,7 +29,7 @@ function saveAttachment(attachment, tries = 0) { return; } - const filepath = getAttachmentPath(attachment.id); + const filepath = getPath(attachment.id); const writeStream = fs.createWriteStream(filepath); https.get(attachment.url, (res) => { @@ -34,19 +46,30 @@ function saveAttachment(attachment, tries = 0) { }); } -function saveAttachments(msg) { +/** + * Attempts to download and save all attachments in the given message + * @param {Eris.Message} msg + * @returns {Promise} + */ +function saveAttachmentsInMessage(msg) { if (! msg.attachments || msg.attachments.length === 0) return Promise.resolve(); return Promise.all(msg.attachments.map(saveAttachment)); } -function getAttachmentUrl(id, desiredName) { +/** + * Returns the self-hosted URL to the given attachment ID + * @param {String} attachmentId + * @param {String=null} desiredName Custom name for the attachment as a hint for the browser + * @returns {String} + */ +function getUrl(attachmentId, desiredName = null) { if (desiredName == null) desiredName = 'file.bin'; - return utils.getSelfUrl(`attachments/${id}/${desiredName}`); + return utils.getSelfUrl(`attachments/${attachmentId}/${desiredName}`); } module.exports = { - getAttachmentPath, + getPath, saveAttachment, - saveAttachments, - getAttachmentUrl, + saveAttachmentsInMessage, + getUrl, }; diff --git a/src/blocked.js b/src/blocked.js index 9569547..ddb9f68 100644 --- a/src/blocked.js +++ b/src/blocked.js @@ -1,20 +1,35 @@ const jsonDb = require('./jsonDb'); +/** + * Checks whether userId is blocked + * @param {String} userId + * @returns {Promise} + */ function isBlocked(userId) { - return jsonDb.get('blocked').then(blocked => { + return jsonDb.get('blocked', []).then(blocked => { return blocked.indexOf(userId) !== -1; }); } +/** + * Blocks the given userId + * @param {String} userId + * @returns {Promise} + */ function block(userId) { - return jsonDb.get('blocked').then(blocked => { + return jsonDb.get('blocked', []).then(blocked => { blocked.push(userId); return jsonDb.save('blocked', blocked); }); } +/** + * Unblocks the given userId + * @param {String} userId + * @returns {Promise} + */ function unblock(userId) { - return jsonDb.get('blocked').then(blocked => { + return jsonDb.get('blocked', []).then(blocked => { blocked.splice(blocked.indexOf(userId), 1); return jsonDb.save('blocked', blocked); }); diff --git a/src/index.js b/src/index.js index 31d2790..63cc2c8 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,8 @@ const Eris = require('eris'); +const fs = require('fs'); const moment = require('moment'); -const Queue = require('./queue'); const config = require('../config'); +const Queue = require('./queue'); const utils = require('./utils'); const blocked = require('./blocked'); const threads = require('./threads'); @@ -23,6 +24,29 @@ bot.on('ready', () => { console.log('Bot started, listening to DMs'); }); +function formatAttachment(attachment) { + let filesize = attachment.size || 0; + filesize /= 1024; + + return attachments.getUrl(attachment.id, attachment.filename).then(attachmentUrl => { + return `**Attachment:** ${attachment.filename} (${filesize.toFixed(1)}KB)\n${attachmentUrl}`; + }); +} + +function formatUserDM(msg) { + let content = msg.content; + + // Get a local URL for all attachments so we don't rely on discord's servers (which delete attachments when the channel/DM thread is deleted) + const attachmentFormatPromise = msg.attachments.map(formatAttachment); + return Promise.all(attachmentFormatPromise).then(formattedAttachments => { + formattedAttachments.forEach(str => { + content += `\n\n${str}`; + }); + + return content; + }); +} + // "Bot was mentioned in #general-discussion" bot.on('messageCreate', msg => { if (msg.author.id === bot.user.id) return; @@ -48,19 +72,20 @@ bot.on('messageCreate', (msg) => { if (isBlocked) return; // Download and save copies of attachments in the background - attachments.saveAttachments(msg); + attachments.saveAttachmentsInMessage(msg); - let thread, logs; + let thread, userLogs; + // Private message handling is queued so e.g. multiple message in quick succession don't result in multiple channels aren't created messageQueue.add(() => { - threads.getForUser(bot, msg.author) + return threads.getForUser(bot, msg.author) .then(userThread => { thread = userThread; return logs.getLogsByUserId(msg.author.id); }) - .then(userLogs => { - logs = userLogs; - return utils.formatUserDM(msg); + .then(foundUserLogs => { + userLogs = foundUserLogs; + return formatUserDM(msg); }) .then(content => { // If the thread does not exist and could not be created, send a warning about this to all mods so they can DM the user directly instead @@ -88,7 +113,7 @@ bot.on('messageCreate', (msg) => { } // Ping mods of the new thread - let creationNotificationMessage = `New modmail thread: <#${channel.id}>`; + let creationNotificationMessage = `New modmail thread: <#${thread.channelId}>`; if (config.pingCreationNotification) creationNotificationMessage = `@here ${creationNotificationMessage}`; bot.createMessage(utils.getModmailGuild(bot).id, { @@ -98,15 +123,15 @@ bot.on('messageCreate', (msg) => { // Send an automatic reply to the user informing them of the successfully created modmail thread msg.channel.createMessage("Thank you for your message! Our mod team will reply to you here as soon as possible.").then(null, (err) => { - bot.createMessage(modMailGuild.id, { + bot.createMessage(utils.getModmailGuild(bot).id, { content: `There is an issue sending messages to ${msg.author.username}#${msg.author.discriminator} (id ${msg.author.id}); consider messaging manually` }); }); } const timestamp = utils.getTimestamp(); - bot.createMessage(channel.id, `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${content}`); - }) + bot.createMessage(thread.channelId, `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${content}`); + }); }); }); }); @@ -144,11 +169,10 @@ bot.registerCommand('reply', (msg, args) => { threads.getByChannelId(msg.channel.id).then(thread => { if (! thread) return; - attachments.saveAttachments(msg).then(() => { + attachments.saveAttachmentsInMessage(msg).then(() => { bot.getDMChannel(thread.userId).then(dmChannel => { - const roleId = msg.member.roles[0]; - const role = (roleId ? (modMailGuild.roles.get(roleId) || {}).name : ''); - const roleStr = (role ? `(${role}) ` : ''); + const mainRole = utils.getMainRole(msg.member); + const roleStr = (mainRole ? `(${mainRole.name}) ` : ''); let argMsg = args.join(' ').trim(); let content = `**${roleStr}${msg.author.username}:** ${argMsg}`; @@ -157,7 +181,7 @@ bot.registerCommand('reply', (msg, args) => { dmChannel.createMessage(content, file).then(() => { if (attachmentUrl) content += `\n\n**Attachment:** ${attachmentUrl}`; - const timestamp = getTimestamp(); + const timestamp = utils.getTimestamp(); msg.channel.createMessage(`[${timestamp}] » ${content}`); }, (err) => { if (err.resp && err.resp.statusCode === 403) { @@ -174,10 +198,10 @@ bot.registerCommand('reply', (msg, args) => { // If the reply has an attachment, relay it as is if (msg.attachments.length > 0) { - fs.readFile(attachments.getAttachmentPath(msg.attachments[0].id), (err, data) => { + fs.readFile(attachments.getPath(msg.attachments[0].id), (err, data) => { const file = {file: data, name: msg.attachments[0].filename}; - getAttachmentUrl(msg.attachments[0].id, msg.attachments[0].filename).then(attachmentUrl => { + attachments.getUrl(msg.attachments[0].id, msg.attachments[0].filename).then(attachmentUrl => { sendMessage(file, attachmentUrl); }); }); @@ -193,7 +217,7 @@ bot.registerCommandAlias('r', 'reply'); bot.registerCommand('close', (msg, args) => { if (! msg.channel.guild) return; - if (msg.channel.guild.id !== modMailGuild.id) return; + if (msg.channel.guild.id !== utils.getModmailGuild(bot).id) return; if (! msg.member.permission.has('manageRoles')) return; threads.getByChannelId(msg.channel.id).then(thread => { @@ -208,7 +232,7 @@ bot.registerCommand('close', (msg, args) => { logs.getNewLogFile(thread.userId).then(logFilename => { logs.saveLogFile(logFilename, log) - .then(() => getLogFileUrl(logFilename)) + .then(() => logs.getLogFileUrl(logFilename)) .then(url => { const closeMessage = `Modmail thread with ${thread.username} (${thread.userId}) was closed by ${msg.author.mention} Logs: <${url}>`; @@ -240,7 +264,7 @@ bot.registerCommand('block', (msg, args) => { // Calling !block without args in a modmail thread blocks the user of that thread threads.getByChannelId(msg.channel.id).then(thread => { if (! thread) return; - block(userId); + block(thread.userId); }); } }); @@ -264,7 +288,7 @@ bot.registerCommand('unblock', (msg, args) => { // Calling !unblock without args in a modmail thread unblocks the user of that thread threads.getByChannelId(msg.channel.id).then(thread => { if (! thread) return; - unblock(userId); + unblock(thread.userId); }); } }); @@ -275,7 +299,7 @@ bot.registerCommand('logs', (msg, args) => { if (! msg.member.permission.has('manageRoles')) return; function getLogs(userId) { - getLogsWithUrlByUserId(userId).then(infos => { + logs.getLogsWithUrlByUserId(userId).then(infos => { let message = `**Log files for <@${userId}>:**\n`; message += infos.map(info => { @@ -295,7 +319,7 @@ bot.registerCommand('logs', (msg, args) => { // Calling !logs without args in a modmail thread returns the logs of the user of that thread threads.getByChannelId(msg.channel.id).then(thread => { if (! thread) return; - getLogs(userId); + getLogs(thread.userId); }); } }); diff --git a/src/jsonDb.js b/src/jsonDb.js index 6852d4f..5427a16 100644 --- a/src/jsonDb.js +++ b/src/jsonDb.js @@ -1,12 +1,13 @@ const fs = require('fs'); const path = require('path'); +const config = require('../config'); -const dbDir = config.dbDir || `${__dirname}/db`; +const dbDir = config.dbDir || `${__dirname}/../db`; const databases = {}; class JSONDB { - constructor(path, def = {}, useCloneByDefault = true) { + constructor(path, def = {}, useCloneByDefault = false) { this.path = path; this.useCloneByDefault = useCloneByDefault; diff --git a/src/logs.js b/src/logs.js index fa62ec6..1d14a4b 100644 --- a/src/logs.js +++ b/src/logs.js @@ -2,10 +2,25 @@ const fs = require('fs'); const crypto = require('crypto'); const moment = require('moment'); const config = require('../config'); +const utils = require('./utils'); -const logDir = config.logDir || `${__dirname}/logs`; +const logDir = config.logDir || `${__dirname}/../logs`; const logFileFormatRegex = /^([0-9\-]+?)__([0-9]+?)__([0-9a-f]+?)\.txt$/; +/** + * @typedef {Object} LogFileInfo + * @property {String} filename + * @property {String} date + * @property {String} userId + * @property {String} token + * @property {String=} url + */ + +/** + * Returns information about the given logfile + * @param {String} logFilename + * @returns {LogFileInfo} + */ function getLogFileInfo(logFilename) { const match = logFilename.match(logFileFormatRegex); if (! match) return null; @@ -20,15 +35,30 @@ function getLogFileInfo(logFilename) { }; } +/** + * Returns the filesystem path to the given logfile + * @param {String} logFilename + * @returns {String} + */ function getLogFilePath(logFilename) { return `${logDir}/${logFilename}`; } +/** + * Returns the self-hosted URL to the given logfile + * @param {String} logFilename + * @returns {String} + */ function getLogFileUrl(logFilename) { const info = getLogFileInfo(logFilename); return utils.getSelfUrl(`logs/${info.token}`); } +/** + * Returns a new, unique log file name for the given userId + * @param {String} userId + * @returns {Promise} + */ function getNewLogFile(userId) { return new Promise(resolve => { crypto.randomBytes(16, (err, buf) => { @@ -40,6 +70,11 @@ function getNewLogFile(userId) { }); } +/** + * Finds a log file name by its token + * @param {String} token + * @returns {Promise} + */ function findLogFile(token) { return new Promise(resolve => { fs.readdir(logDir, (err, files) => { @@ -55,9 +90,16 @@ function findLogFile(token) { }); } +/** + * Returns all log file infos for the given userId + * @param {String} userId + * @returns {Promise} + */ function getLogsByUserId(userId) { - return new Promise(resolve => { + return new Promise((resolve, reject) => { fs.readdir(logDir, (err, files) => { + if (err) return reject(err); + const logfileInfos = files .map(file => getLogFileInfo(file)) .filter(info => info && info.userId === userId); @@ -67,6 +109,11 @@ function getLogsByUserId(userId) { }); } +/** + * Returns all log file infos with URLs for the given userId + * @param {String} userId + * @returns {Promise} + */ function getLogsWithUrlByUserId(userId) { return getLogsByUserId(userId).then(infos => { const urlPromises = infos.map(info => { @@ -88,6 +135,11 @@ function getLogsWithUrlByUserId(userId) { }); } +/** + * @param {String} logFilename + * @param {String} content + * @returns {Promise} + */ function saveLogFile(logFilename, content) { return new Promise((resolve, reject) => { fs.writeFile(getLogFilePath(logFilename), content, {encoding: 'utf8'}, err => { @@ -105,4 +157,5 @@ module.exports = { getLogsByUserId, getLogsWithUrlByUserId, saveLogFile, + getLogFileUrl, }; diff --git a/src/threads.js b/src/threads.js index e93ba2d..fd57bad 100644 --- a/src/threads.js +++ b/src/threads.js @@ -16,10 +16,10 @@ const jsonDb = require('./jsonDb'); * @param {Eris.Client} bot * @param {Eris.User} user * @param {Boolean} allowCreate - * @returns {Promise} + * @returns {Promise} */ function getForUser(bot, user, allowCreate = true) { - return jsonDb.get('threads').then(threads => { + return jsonDb.get('threads', []).then(threads => { const thread = threads.find(t => t.userId === user.id); if (thread) return thread; @@ -41,12 +41,12 @@ function getForUser(bot, user, allowCreate = true) { username: `${user.username}#${user.discriminator}`, }; - const threads = jsonDb.get('threads'); - threads.push(thread); - jsonDb.save('threads', threads); + return jsonDb.get('threads', []).then(threads => { + threads.push(thread); + jsonDb.save('threads', threads); - thread._wasCreated = true; - return thread; + return Object.assign({}, thread, {_wasCreated: true}); + }); }, err => { console.error(`Error creating modmail channel for ${user.username}#${user.discriminator}!`); }); @@ -55,17 +55,22 @@ function getForUser(bot, user, allowCreate = true) { /** * @param {String} channelId - * @returns {Promise} + * @returns {Promise} */ function getByChannelId(channelId) { - return jsonDb.get('threads').then(threads => { - return threads.find(t => t.userId === user.id); + return jsonDb.get('threads', []).then(threads => { + return threads.find(t => t.channelId === channelId); }); } +/** + * Deletes the modmail thread for the given channel id + * @param {String} channelId + * @returns {Promise} + */ function close(channelId) { - return jsonDb.get('threads').then(threads => { - const thread = threads.find(t => t.userId === user.id); + return jsonDb.get('threads', []).then(threads => { + const thread = threads.find(t => t.channelId === channelId); if (! thread) return; threads.splice(threads.indexOf(thread), 1); diff --git a/src/utils.js b/src/utils.js index dcb9ba6..2c843dd 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,37 +1,22 @@ +const Eris = require('eris'); const moment = require('moment'); const publicIp = require('public-ip'); const config = require('../config'); const utils = require('./utils'); +let modMailGuild = null; function getModmailGuild(bot) { - return bot.guilds.find(g => g.id === config.mailGuildId); -} - -function formatAttachment(attachment) { - let filesize = attachment.size || 0; - filesize /= 1024; - - return utils.getAttachmentUrl(attachment.id, attachment.filename).then(attachmentUrl => { - return `**Attachment:** ${attachment.filename} (${filesize.toFixed(1)}KB)\n${attachmentUrl}`; - }); -} - -function formatUserDM(msg) { - let content = msg.content; - - // Get a local URL for all attachments so we don't rely on discord's servers (which delete attachments when the channel/DM thread is deleted) - const attachmentFormatPromise = msg.attachments.map(formatAttachment); - return Promise.all(attachmentFormatPromise).then(formattedAttachments => { - formattedAttachments.forEach(str => { - content += `\n\n${str}`; - }); - - return content; - }); + if (! modMailGuild) modMailGuild = bot.guilds.find(g => g.id === config.mailGuildId); + return modMailGuild; } const userMentionRegex = /^<@\!?([0-9]+?)>$/; +/** + * Returns the user ID of the user mentioned in str, if any + * @param {String} str + * @returns {String|null} + */ function getUserMention(str) { str = str.trim(); @@ -46,30 +31,56 @@ function getUserMention(str) { return null; } +/** + * Returns the current timestamp in an easily readable form + * @param {String|Date|undefined} date + * @returns {String} + */ function getTimestamp(date) { return moment.utc(date).format('HH:mm'); } +/** + * Disables link previews in the given string by wrapping links in < > + * @param {String} str + * @returns {String} + */ function disableLinkPreviews(str) { return str.replace(/(^|[^<])(https?:\/\/\S+)/ig, '$1<$2>'); } -function getSelfUrl(path) { +/** + * Returns a URL to the bot's web server + * @param {String} path + * @returns {String} + */ +function getSelfUrl(path = '') { if (config.url) { return Promise.resolve(`${config.url}/${path}`); } else { + const port = config.port || 8890; return publicIp.v4().then(ip => { - return `http://${ip}:${logServerPort}/${path}`; + return `http://${ip}:${port}/${path}`; }); } } +/** + * Returns the highest hoisted role of the given member + * @param {Eris.Member} member + * @returns {Eris.Role} + */ +function getMainRole(member) { + const roles = member.roles.map(id => member.guild.roles.get(id)); + roles.sort((a, b) => a.position > b.position ? -1 : 1); + return roles.find(r => r.hoist); +} + module.exports = { getModmailGuild, - formatAttachment, - formatUserDM, getUserMention, getTimestamp, disableLinkPreviews, getSelfUrl, + getMainRole, }; diff --git a/src/webserver.js b/src/webserver.js index 636540e..0d3c3b1 100644 --- a/src/webserver.js +++ b/src/webserver.js @@ -1,6 +1,10 @@ const http = require('http'); const mime = require('mime'); +const url = require('url'); +const fs = require('fs'); const config = require('../config'); +const logs = require('./logs'); +const attachments = require('./attachments'); const port = config.port || 8890; @@ -8,10 +12,10 @@ function serveLogs(res, pathParts) { const token = pathParts[pathParts.length - 1]; if (token.match(/^[0-9a-f]+$/) === null) return res.end(); - findLogFile(token).then(logfile => { - if (logfile === null) return res.end(); + logs.findLogFile(token).then(logFilename => { + if (logFilename === null) return res.end(); - fs.readFile(getLogFilePath(logfile), {encoding: 'utf8'}, (err, data) => { + fs.readFile(logs.getLogFilePath(logFilename), {encoding: 'utf8'}, (err, data) => { if (err) { res.statusCode = 404; res.end('Log not found'); @@ -31,7 +35,7 @@ function serveAttachments(res, pathParts) { if (id.match(/^[0-9]+$/) === null) return res.end(); if (desiredFilename.match(/^[0-9a-z\._-]+$/i) === null) return res.end(); - const attachmentPath = getAttachmentPath(id); + const attachmentPath = attachments.getPath(id); fs.access(attachmentPath, (err) => { if (err) { res.statusCode = 404; @@ -59,7 +63,7 @@ function run() { if (parsedUrl.path.startsWith('/attachments/')) serveAttachments(res, pathParts); }); - server.listen(logServerPort); + server.listen(port); } module.exports = { diff --git a/update.sh b/update.sh index 0884969..fa83291 100644 --- a/update.sh +++ b/update.sh @@ -1,4 +1,5 @@ #!/bin/bash git pull +yarn pm2 restart ModmailBot