diff --git a/.eslintrc b/.eslintrc index 359878d..720caa0 100644 --- a/.eslintrc +++ b/.eslintrc @@ -11,6 +11,14 @@ }, "rules": { - + "space-infix-ops": "error", + "space-unary-ops": ["error", { + "words": true, + "nonwords": false, + "overrides": { + "!": true, + "!!": true + } + }] } } diff --git a/.gitignore b/.gitignore index 57172cc..56f7072 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ /.vscode +/.idea /node_modules /config.json -/blocked.json diff --git a/db/.gitignore b/db/.gitignore new file mode 100644 index 0000000..120f485 --- /dev/null +++ b/db/.gitignore @@ -0,0 +1,2 @@ +* +!/.gitignore diff --git a/index.js b/index.js deleted file mode 100644 index e98a30e..0000000 --- a/index.js +++ /dev/null @@ -1,601 +0,0 @@ -const fs = require('fs'); -const http = require('http'); -const https = require('https'); -const url = require('url'); -const crypto = require('crypto'); -const publicIp = require('public-ip'); -const Eris = require('eris'); -const moment = require('moment'); -const mime = require('mime'); -const Queue = require('./queue'); -const config = require('./config'); - -const logServerPort = config.port || 8890; - -const bot = new Eris.CommandClient(config.token, {}, { - prefix: config.prefix || '!', - ignoreSelf: true, - ignoreBots: true, - defaultHelpCommand: false, -}); - -let modMailGuild; -const modMailChannels = {}; -const messageQueue = new Queue(); - -const blockFile = `${__dirname}/blocked.json`; -let blocked = []; - -const logDir = `${__dirname}/logs`; -const logFileFormatRegex = /^([0-9\-]+?)__([0-9]+?)__([0-9a-f]+?)\.txt$/; - -const userMentionRegex = /^<@\!?([0-9]+?)>$/; - -const attachmentDir = `${__dirname}/attachments`; - -try { - const blockedJSON = fs.readFileSync(blockFile, {encoding: 'utf8'}); - blocked = JSON.parse(blockedJSON); -} catch(e) { - fs.writeFileSync(blockFile, '[]'); -} - -function saveBlocked() { - fs.writeFileSync(blockFile, JSON.stringify(blocked, null, 4)); -} - -/* - * MODMAIL LOG UTILITY FUNCTIONS - */ - -function getLogFileInfo(logfile) { - const match = logfile.match(logFileFormatRegex); - if (! match) return null; - - const date = moment.utc(match[1], 'YYYY-MM-DD-HH-mm-ss').format('YYYY-MM-DD HH:mm:ss'); - - return { - filename: logfile, - date: date, - userId: match[2], - token: match[3], - }; -} - -function getLogFilePath(logfile) { - return `${logDir}/${logfile}`; -} - -function getLogFileUrl(logfile) { - const info = getLogFileInfo(logfile); - - return publicIp.v4().then(ip => { - return `http://${ip}:${logServerPort}/logs/${info.token}`; - }); -} - -function getRandomLogFile(userId) { - return new Promise(resolve => { - crypto.randomBytes(16, (err, buf) => { - const token = buf.toString('hex'); - const date = moment.utc().format('YYYY-MM-DD-HH-mm-ss'); - - resolve(`${date}__${userId}__${token}.txt`); - }); - }); -} - -function findLogFile(token) { - return new Promise(resolve => { - fs.readdir(logDir, (err, files) => { - for (const file of files) { - if (file.endsWith(`__${token}.txt`)) { - resolve(file); - return; - } - } - - resolve(null); - }); - }); -} - -function getLogsByUserId(userId) { - return new Promise(resolve => { - fs.readdir(logDir, (err, files) => { - const logfileInfos = files - .map(file => getLogFileInfo(file)) - .filter(info => info && info.userId === userId); - - resolve(logfileInfos); - }); - }); -} - -function getLogsWithUrlByUserId(userId) { - return getLogsByUserId(userId).then(infos => { - const urlPromises = infos.map(info => { - return getLogFileUrl(info.filename).then(url => { - info.url = url; - return info; - }); - }); - - return Promise.all(urlPromises).then(infos => { - infos.sort((a, b) => { - if (a.date > b.date) return 1; - if (a.date < b.date) return -1; - return 0; - }); - - return infos; - }); - }); -} - -/* - * Attachments - */ - -function getAttachmentPath(id) { - return `${attachmentDir}/${id}`; -} - -function saveAttachment(attachment, tries = 0) { - return new Promise((resolve, reject) => { - if (tries > 3) { - console.error('Attachment download failed after 3 tries:', attachment); - reject('Attachment download failed after 3 tries'); - return; - } - - const filepath = getAttachmentPath(attachment.id); - const writeStream = fs.createWriteStream(filepath); - - https.get(attachment.url, (res) => { - res.pipe(writeStream); - writeStream.on('finish', () => { - writeStream.close() - resolve(); - }); - }).on('error', (err) => { - fs.unlink(filepath); - console.error('Error downloading attachment, retrying'); - resolve(saveAttachment(attachment)); - }); - }); -} - -function saveAttachments(msg) { - if (! msg.attachments || msg.attachments.length === 0) return Promise.resolve(); - return Promise.all(msg.attachments.map(saveAttachment)); -} - -function getAttachmentUrl(id, desiredName) { - if (desiredName == null) desiredName = 'file.bin'; - - return publicIp.v4().then(ip => { - return `http://${ip}:${logServerPort}/attachments/${id}/${desiredName}`; - }); -} - -/* - * MAIN FUNCTIONALITY - */ - -bot.on('ready', () => { - modMailGuild = bot.guilds.find(g => g.id === config.mailGuildId); - - if (! modMailGuild) { - console.error('You need to set and invite me to the mod mail guild first!'); - process.exit(0); - } - - bot.editStatus(null, {name: config.status || 'Message me for help'}); - console.log('Bot started, listening to DMs'); -}); - -function getModmailChannelInfo(channel) { - if (! channel.topic) return null; - - const match = channel.topic.match(/^MODMAIL\|([0-9]+)\|(.*)$/); - if (! match) return null; - - return { - userId: match[1], - name: match[2], - }; -} - -// This function doesn't actually return the channel object, but an object like {id, _wasCreated} -// This is because we can't trust the guild.channels list to actually contain our channel, even if it exists -// (has to do with the api events' consistency), and we only cache IDs, so we return an object that can be -// constructed from just the ID; we do this even if we find a matching channel so the returned object's signature is consistent -function getModmailChannel(user, allowCreate = true) { - // If the channel id's in the cache, use that - if (modMailChannels[user.id]) { - return Promise.resolve({ - id: modMailChannels[user.id], - _wasCreated: false, - }); - } - - // Try to find a matching channel - let candidate = modMailGuild.channels.find(c => { - const info = getModmailChannelInfo(c); - return info && info.userId === user.id; - }); - - if (candidate) { - // If we found a matching channel, return that - return Promise.resolve({ - id: candidate.id, - _wasCreated: false, - }); - } else { - // If one is not found, create and cache it - if (! allowCreate) return Promise.resolve(null); - - let cleanName = user.username.replace(/[^a-zA-Z0-9]/ig, '').toLowerCase().trim(); - if (cleanName === '') cleanName = 'unknown'; - - console.log(`[NOTE] Since no candidate was found, creating channel ${cleanName}-${user.discriminator}`); - return modMailGuild.createChannel(`${cleanName}-${user.discriminator}`) - .then(channel => { - // This is behind a timeout because Discord was telling me the channel didn't exist after creation even though it clearly did - // ¯\_(ツ)_/¯ - return new Promise(resolve => { - const topic = `MODMAIL|${user.id}|${user.username}#${user.discriminator}`; - setTimeout(() => resolve(channel.edit({topic: topic})), 200); - }); - }, err => { - console.error(`Error creating modmail channel for ${user.username}#${user.discriminator}!`); - }) - .then(channel => { - modMailChannels[user.id] = channel.id; - - return { - id: channel.id, - _wasCreated: true, - }; - }); - } -} - -function formatAttachment(attachment) { - let filesize = attachment.size || 0; - filesize /= 1024; - - return getAttachmentUrl(attachment.id, attachment.filename).then(attachmentUrl => { - return `**Attachment:** ${attachment.filename} (${filesize.toFixed(1)}KB)\n${attachmentUrl}`; - }); -} - -function formatRelayedPM(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; - }); -} - -function getTimestamp(date) { - return moment.utc(date).format('HH:mm'); -} - -// When we get a private message, create a modmail channel or reuse an existing one. -// If the channel was not reused, assume it's a new modmail thread and send the user an introduction message. -bot.on('messageCreate', (msg) => { - if (! (msg.channel instanceof Eris.PrivateChannel)) return; - if (msg.author.id === bot.user.id) return; - - if (blocked.indexOf(msg.author.id) !== -1) return; - - saveAttachments(msg); - - // This needs to be queued, as otherwise if a user sent a bunch of messages initially and the createChannel endpoint is delayed, we might get duplicate channels - messageQueue.add(() => { - return getModmailChannel(msg.author).then(channel => { - return formatRelayedPM(msg).then(content => { - // Get previous modmail logs for this user - // Show a note of them at the beginning of the thread for reference - return getLogsByUserId(msg.author.id).then(logs => { - if (channel._wasCreated) { - if (logs.length > 0) { - bot.createMessage(channel.id, `${logs.length} previous modmail logs with this user. Use !logs ${msg.author.id} for details.`); - } - - let creationNotificationMessage = `New modmail thread: <#${channel.id}>`; - if (config.pingCreationNotification) creationNotificationMessage = `@here ${creationNotificationMessage}`; - - bot.createMessage(modMailGuild.id, { - content: creationNotificationMessage, - disableEveryone: false, - }); - - 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, { - content: `There is an issue sending messages to ${msg.author.username}#${msg.author.discriminator} (id ${msg.author.id}); consider messaging manually` - }); - }); - } - - const timestamp = getTimestamp(); - bot.createMessage(channel.id, `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${content}`); - }); // getLogsByUserId - }); // formatRelayedPM - }); // getModmailChannel - }); // messageQueue.add -}); - -// Edits in PMs -bot.on('messageUpdate', (msg, oldMessage) => { - if (! (msg.channel instanceof Eris.PrivateChannel)) return; - if (msg.author.id === bot.user.id) return; - - if (blocked.indexOf(msg.author.id) !== -1) return; - - let oldContent = oldMessage.content; - const newContent = msg.content; - - if (oldContent == null) oldContent = '*Unavailable due to bot restart*'; - - getModmailChannel(msg.author, false).then(channel => { - if (! channel) return; - bot.createMessage(channel.id, `**The user edited their message:**\n**Before:** ${oldContent}\n**After:** ${newContent}`); - }); -}); - -// "Bot was mentioned in #general-discussion" -bot.on('messageCreate', msg => { - if (msg.author.id === bot.user.id) return; - if (blocked.indexOf(msg.author.id) !== -1) return; - - if (msg.mentions.some(user => user.id === bot.user.id)) { - bot.createMessage(modMailGuild.id, { - content: `@here Bot mentioned in ${msg.channel.mention} by **${msg.author.username}#${msg.author.discriminator}**: "${msg.cleanContent}"`, - disableEveryone: false, - }); - } -}); - -// Mods can reply to modmail threads using !r or !reply -// These messages get relayed back to the DM thread between the bot and the user -// Attachments are shown as URLs -bot.registerCommand('reply', (msg, args) => { - if (! msg.channel.guild) return; - if (msg.channel.guild.id !== modMailGuild.id) return; - if (! msg.member.permission.has('manageRoles')) return; - - const channelInfo = getModmailChannelInfo(msg.channel); - if (! channelInfo) return; - - saveAttachments(msg).then(() => { - bot.getDMChannel(channelInfo.userId).then(dmChannel => { - const roleId = msg.member.roles[0]; - const role = (roleId ? (modMailGuild.roles.get(roleId) || {}).name : ''); - const roleStr = (role ? `(${role}) ` : ''); - - let argMsg = args.join(' ').trim(); - let content = `**${roleStr}${msg.author.username}:** ${argMsg}`; - - const sendMessage = (file, attachmentUrl) => { - dmChannel.createMessage(content, file).then(() => { - if (attachmentUrl) content += `\n\n**Attachment:** ${attachmentUrl}`; - - const timestamp = getTimestamp(); - msg.channel.createMessage(`[${timestamp}] » ${content}`); - }, (err) => { - if (err.resp && err.resp.statusCode === 403) { - msg.channel.createMessage(`Could not send reply; the user has likely blocked the bot`); - } else if (err.resp) { - msg.channel.createMessage(`Could not send reply; error code ${err.resp.statusCode}`); - } else { - msg.channel.createMessage(`Could not send reply: ${err.toString()}`); - } - }); - - msg.delete(); - }; - - if (msg.attachments.length > 0) { - fs.readFile(getAttachmentPath(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 => { - sendMessage(file, attachmentUrl); - }); - }); - } else { - sendMessage(); - } - }); - }); -}); - -bot.registerCommandAlias('r', 'reply'); - -bot.registerCommand('close', (msg, args) => { - if (! msg.channel.guild) return; - if (msg.channel.guild.id !== modMailGuild.id) return; - if (! msg.member.permission.has('manageRoles')) return; - - const channelInfo = getModmailChannelInfo(msg.channel); - if (! channelInfo) return; - - msg.channel.createMessage('Saving logs and closing channel...'); - msg.channel.getMessages(10000).then(messages => { - const log = messages.reverse().map(message => { - const date = moment.utc(message.timestamp, 'x').format('YYYY-MM-DD HH:mm:ss'); - return `[${date}] ${message.author.username}#${message.author.discriminator}: ${message.content}`; - }).join('\n') + '\n'; - - getRandomLogFile(channelInfo.userId).then(logfile => { - fs.writeFile(getLogFilePath(logfile), log, {encoding: 'utf8'}, err => { - getLogFileUrl(logfile).then(logurl => { - const closeMessage = `Modmail thread with ${channelInfo.name} (${channelInfo.userId}) was closed by ${msg.author.mention} -Logs: <${logurl}>`; - - bot.createMessage(modMailGuild.id, closeMessage); - - delete modMailChannels[channelInfo.userId]; - msg.channel.delete(); - }); - }); - }) - }); -}); - -bot.registerCommand('block', (msg, args) => { - if (! msg.channel.guild) return; - if (msg.channel.guild.id !== modMailGuild.id) return; - if (! msg.member.permission.has('manageRoles')) return; - - let userId; - if (args[0]) { - if (args[0].match(/^[0-9]+$/)) { - userId = args[0]; - } else { - let mentionMatch = args[0].match(userMentionRegex); - if (mentionMatch) userId = mentionMatch[1]; - } - } else { - const modmailChannelInfo = getModmailChannelInfo(msg.channel); - if (modmailChannelInfo) userId = modmailChannelInfo.userId; - } - - if (! userId) return; - - blocked.push(userId); - saveBlocked(); - msg.channel.createMessage(`Blocked <@${userId}> (id ${userId}) from modmail`); -}); - -bot.registerCommand('unblock', (msg, args) => { - if (! msg.channel.guild) return; - if (msg.channel.guild.id !== modMailGuild.id) return; - if (! msg.member.permission.has('manageRoles')) return; - - let userId; - if (args[0]) { - if (args[0].match(/^[0-9]+$/)) { - userId = args[0]; - } else { - let mentionMatch = args[0].match(userMentionRegex); - if (mentionMatch) userId = mentionMatch[1]; - } - } else { - const modmailChannelInfo = getModmailChannelInfo(msg.channel); - if (modmailChannelInfo) userId = modmailChannelInfo.userId; - } - - if (! userId) return; - - blocked.splice(blocked.indexOf(userId), 1); - saveBlocked(); - msg.channel.createMessage(`Unblocked <@${userId}> (id ${userId}) from modmail`); -}); - -bot.registerCommand('logs', (msg, args) => { - if (! msg.channel.guild) return; - if (msg.channel.guild.id !== modMailGuild.id) return; - if (! msg.member.permission.has('manageRoles')) return; - - let userId; - if (args[0]) { - if (args[0].match(/^[0-9]+$/)) { - userId = args[0]; - } else { - let mentionMatch = args[0].match(userMentionRegex); - if (mentionMatch) userId = mentionMatch[1]; - } - } else { - const modmailChannelInfo = getModmailChannelInfo(msg.channel); - if (modmailChannelInfo) userId = modmailChannelInfo.userId; - } - - if (! userId) return; - - getLogsWithUrlByUserId(userId).then(infos => { - let message = `**Log files for <@${userId}>:**\n`; - - message += infos.map(info => { - const formattedDate = moment.utc(info.date, 'YYYY-MM-DD HH:mm:ss').format('MMM Do [at] HH:mm [UTC]'); - return `\`${formattedDate}\`: <${info.url}>`; - }).join('\n'); - - msg.channel.createMessage(message); - }); -}); - -bot.on('channelCreate', channel => { - console.log(`[NOTE] Got channel creation event for #${channel.name} (ID ${channel.id})`); -}); - -bot.connect(); - -/* - * MODMAIL LOG SERVER - */ - -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(); - - fs.readFile(getLogFilePath(logfile), {encoding: 'utf8'}, (err, data) => { - if (err) { - res.statusCode = 404; - res.end('Log not found'); - return; - } - - res.setHeader('Content-Type', 'text/plain; charset=UTF-8'); - res.end(data); - }); - }); -} - -function serveAttachments(res, pathParts) { - const desiredFilename = pathParts[pathParts.length - 1]; - const id = pathParts[pathParts.length - 2]; - - if (id.match(/^[0-9]+$/) === null) return res.end(); - if (desiredFilename.match(/^[0-9a-z\._-]+$/i) === null) return res.end(); - - const attachmentPath = getAttachmentPath(id); - fs.access(attachmentPath, (err) => { - if (err) { - res.statusCode = 404; - res.end('Attachment not found'); - return; - } - - const filenameParts = desiredFilename.split('.'); - const ext = (filenameParts.length > 1 ? filenameParts[filenameParts.length - 1] : 'bin'); - const fileMime = mime.lookup(ext); - - res.setHeader('Content-Type', fileMime); - - const read = fs.createReadStream(attachmentPath); - read.pipe(res); - }) -} - -const server = http.createServer((req, res) => { - const parsedUrl = url.parse(`http://${req.url}`); - const pathParts = parsedUrl.path.split('/').filter(v => v !== ''); - - if (parsedUrl.path.startsWith('/logs/')) serveLogs(res, pathParts); - if (parsedUrl.path.startsWith('/attachments/')) serveAttachments(res, pathParts); -}); - -server.listen(logServerPort); diff --git a/src/attachments.js b/src/attachments.js new file mode 100644 index 0000000..82ba333 --- /dev/null +++ b/src/attachments.js @@ -0,0 +1,52 @@ +const fs = require('fs'); +const https = require('https'); +const config = require('../config'); +const utils = require('./utils'); + +const attachmentDir = config.attachmentDir || `${__dirname}/attachments`; + +function getAttachmentPath(id) { + return `${attachmentDir}/${id}`; +} + +function saveAttachment(attachment, tries = 0) { + return new Promise((resolve, reject) => { + if (tries > 3) { + console.error('Attachment download failed after 3 tries:', attachment); + reject('Attachment download failed after 3 tries'); + return; + } + + const filepath = getAttachmentPath(attachment.id); + const writeStream = fs.createWriteStream(filepath); + + https.get(attachment.url, (res) => { + res.pipe(writeStream); + writeStream.on('finish', () => { + writeStream.close() + resolve(); + }); + }).on('error', (err) => { + fs.unlink(filepath); + console.error('Error downloading attachment, retrying'); + resolve(saveAttachment(attachment)); + }); + }); +} + +function saveAttachments(msg) { + if (! msg.attachments || msg.attachments.length === 0) return Promise.resolve(); + return Promise.all(msg.attachments.map(saveAttachment)); +} + +function getAttachmentUrl(id, desiredName) { + if (desiredName == null) desiredName = 'file.bin'; + return utils.getSelfUrl(`attachments/${id}/${desiredName}`); +} + +module.exports = { + getAttachmentPath, + saveAttachment, + saveAttachments, + getAttachmentUrl, +}; diff --git a/src/blocked.js b/src/blocked.js new file mode 100644 index 0000000..9569547 --- /dev/null +++ b/src/blocked.js @@ -0,0 +1,27 @@ +const jsonDb = require('./jsonDb'); + +function isBlocked(userId) { + return jsonDb.get('blocked').then(blocked => { + return blocked.indexOf(userId) !== -1; + }); +} + +function block(userId) { + return jsonDb.get('blocked').then(blocked => { + blocked.push(userId); + return jsonDb.save('blocked', blocked); + }); +} + +function unblock(userId) { + return jsonDb.get('blocked').then(blocked => { + blocked.splice(blocked.indexOf(userId), 1); + return jsonDb.save('blocked', blocked); + }); +} + +module.exports = { + isBlocked, + block, + unblock, +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..31d2790 --- /dev/null +++ b/src/index.js @@ -0,0 +1,304 @@ +const Eris = require('eris'); +const moment = require('moment'); +const Queue = require('./queue'); +const config = require('../config'); +const utils = require('./utils'); +const blocked = require('./blocked'); +const threads = require('./threads'); +const logs = require('./logs'); +const attachments = require('./attachments'); +const webserver = require('./webserver'); + +const bot = new Eris.CommandClient(config.token, {}, { + prefix: config.prefix || '!', + ignoreSelf: true, + ignoreBots: true, + defaultHelpCommand: false, +}); + +const messageQueue = new Queue(); + +bot.on('ready', () => { + bot.editStatus(null, {name: config.status || 'Message me for help'}); + console.log('Bot started, listening to DMs'); +}); + +// "Bot was mentioned in #general-discussion" +bot.on('messageCreate', msg => { + if (msg.author.id === bot.user.id) return; + + if (msg.mentions.some(user => user.id === bot.user.id)) { + blocked.isBlocked(msg.author.id).then(isBlocked => { + if (isBlocked) return; + + bot.createMessage(utils.getModmailGuild(bot).id, { + content: `@here Bot mentioned in ${msg.channel.mention} by **${msg.author.username}#${msg.author.discriminator}**: "${msg.cleanContent}"`, + disableEveryone: false, + }); + }); + } +}); + +// When we get a private message, forward the contents to the corresponding modmail thread +bot.on('messageCreate', (msg) => { + if (! (msg.channel instanceof Eris.PrivateChannel)) return; + if (msg.author.id === bot.user.id) return; + + blocked.isBlocked(msg.author.id).then(isBlocked => { + if (isBlocked) return; + + // Download and save copies of attachments in the background + attachments.saveAttachments(msg); + + let thread, logs; + + messageQueue.add(() => { + threads.getForUser(bot, msg.author) + .then(userThread => { + thread = userThread; + return logs.getLogsByUserId(msg.author.id); + }) + .then(userLogs => { + logs = userLogs; + return utils.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 + if (! thread) { + let warningMessage = ` + @here Error creating modmail thread for ${msg.author.username}#${msg.author.discriminator} (${msg.author.id})! + + Here's what their message contained: + \`\`\`${content}\`\`\` + `; + + bot.createMessage(utils.getModmailGuild(bot).id, { + content: `@here Error creating modmail thread for ${msg.author.username}#${msg.author.discriminator} (${msg.author.id})!`, + disableEveryone: false, + }); + + return; + } + + // If the thread was just created, do some extra stuff + if (thread._wasCreated) { + // Mention previous logs at the start of the thread + if (logs.length > 0) { + bot.createMessage(thread.channelId, `${logs.length} previous modmail logs with this user. Use !logs ${msg.author.id} for details.`); + } + + // Ping mods of the new thread + let creationNotificationMessage = `New modmail thread: <#${channel.id}>`; + if (config.pingCreationNotification) creationNotificationMessage = `@here ${creationNotificationMessage}`; + + bot.createMessage(utils.getModmailGuild(bot).id, { + content: creationNotificationMessage, + disableEveryone: false, + }); + + // 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, { + 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}`); + }) + }); + }); +}); + +// Edits in DMs +bot.on('messageUpdate', (msg, oldMessage) => { + if (! (msg.channel instanceof Eris.PrivateChannel)) return; + if (msg.author.id === bot.user.id) return; + + blocked.isBlocked(msg.author.id).then(isBlocked => { + if (isBlocked) return; + + let oldContent = oldMessage.content; + const newContent = msg.content; + + if (oldContent == null) oldContent = '*Unavailable due to bot restart*'; + + threads.getForUser(bot, msg.author).then(thread => { + if (! thread) return; + + const editMessage = utils.disableLinkPreviews(`**The user edited their message:**\n**Before:** ${oldContent}\n**After:** ${newContent}`); + + bot.createMessage(thread.channelId, editMessage); + }); + }); +}); + +// Mods can reply to modmail threads using !r or !reply +// These messages get relayed back to the DM thread between the bot and the user +bot.registerCommand('reply', (msg, args) => { + if (! msg.channel.guild) 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 => { + if (! thread) return; + + attachments.saveAttachments(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}) ` : ''); + + let argMsg = args.join(' ').trim(); + let content = `**${roleStr}${msg.author.username}:** ${argMsg}`; + + function sendMessage(file, attachmentUrl) { + dmChannel.createMessage(content, file).then(() => { + if (attachmentUrl) content += `\n\n**Attachment:** ${attachmentUrl}`; + + const timestamp = getTimestamp(); + msg.channel.createMessage(`[${timestamp}] » ${content}`); + }, (err) => { + if (err.resp && err.resp.statusCode === 403) { + msg.channel.createMessage(`Could not send reply; the user has likely blocked the bot`); + } else if (err.resp) { + msg.channel.createMessage(`Could not send reply; error code ${err.resp.statusCode}`); + } else { + msg.channel.createMessage(`Could not send reply: ${err.toString()}`); + } + }); + + msg.delete(); + }; + + // 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) => { + const file = {file: data, name: msg.attachments[0].filename}; + + getAttachmentUrl(msg.attachments[0].id, msg.attachments[0].filename).then(attachmentUrl => { + sendMessage(file, attachmentUrl); + }); + }); + } else { + sendMessage(); + } + }); + }); + }); +}); + +bot.registerCommandAlias('r', 'reply'); + +bot.registerCommand('close', (msg, args) => { + if (! msg.channel.guild) return; + if (msg.channel.guild.id !== modMailGuild.id) return; + if (! msg.member.permission.has('manageRoles')) return; + + threads.getByChannelId(msg.channel.id).then(thread => { + if (! thread) return; + + msg.channel.createMessage('Saving logs and closing channel...'); + msg.channel.getMessages(10000).then(messages => { + const log = messages.reverse().map(msg => { + const date = moment.utc(msg.timestamp, 'x').format('YYYY-MM-DD HH:mm:ss'); + return `[${date}] ${msg.author.username}#${msg.author.discriminator}: ${msg.content}`; + }).join('\n') + '\n'; + + logs.getNewLogFile(thread.userId).then(logFilename => { + logs.saveLogFile(logFilename, log) + .then(() => getLogFileUrl(logFilename)) + .then(url => { + const closeMessage = `Modmail thread with ${thread.username} (${thread.userId}) was closed by ${msg.author.mention} +Logs: <${url}>`; + + bot.createMessage(utils.getModmailGuild(bot).id, closeMessage); + threads.close(thread.channelId).then(() => msg.channel.delete()); + }); + }); + }); + }); +}); + +bot.registerCommand('block', (msg, args) => { + if (! msg.channel.guild) return; + if (msg.channel.guild.id !== utils.getModmailGuild(bot).id) return; + if (! msg.member.permission.has('manageRoles')) return; + + function block(userId) { + blocked.block(userId).then(() => { + msg.channel.createMessage(`Blocked <@${userId}> (id ${userId}) from modmail`); + }); + } + + if (args.length > 0) { + const userId = utils.getUserMention(args.join(' ')); + if (! userId) return; + block(userId); + } else { + // 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); + }); + } +}); + +bot.registerCommand('unblock', (msg, args) => { + if (! msg.channel.guild) return; + if (msg.channel.guild.id !== utils.getModmailGuild(bot).id) return; + if (! msg.member.permission.has('manageRoles')) return; + + function unblock(userId) { + blocked.unblock(userId).then(() => { + msg.channel.createMessage(`Unblocked <@${userId}> (id ${userId}) from modmail`); + }); + } + + if (args.length > 0) { + const userId = utils.getUserMention(args.join(' ')); + if (! userId) return; + unblock(userId); + } else { + // 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); + }); + } +}); + +bot.registerCommand('logs', (msg, args) => { + if (! msg.channel.guild) return; + if (msg.channel.guild.id !== utils.getModmailGuild(bot).id) return; + if (! msg.member.permission.has('manageRoles')) return; + + function getLogs(userId) { + getLogsWithUrlByUserId(userId).then(infos => { + let message = `**Log files for <@${userId}>:**\n`; + + message += infos.map(info => { + const formattedDate = moment.utc(info.date, 'YYYY-MM-DD HH:mm:ss').format('MMM Do [at] HH:mm [UTC]'); + return `\`${formattedDate}\`: <${info.url}>`; + }).join('\n'); + + msg.channel.createMessage(message); + }); + } + + if (args.length > 0) { + const userId = utils.getUserMention(args.join(' ')); + if (! userId) return; + getLogs(userId); + } else { + // 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); + }); + } +}); + +bot.connect(); +webserver.run(); diff --git a/src/jsonDb.js b/src/jsonDb.js new file mode 100644 index 0000000..6852d4f --- /dev/null +++ b/src/jsonDb.js @@ -0,0 +1,67 @@ +const fs = require('fs'); +const path = require('path'); + +const dbDir = config.dbDir || `${__dirname}/db`; + +const databases = {}; + +class JSONDB { + constructor(path, def = {}, useCloneByDefault = true) { + this.path = path; + this.useCloneByDefault = useCloneByDefault; + + this.load = new Promise(resolve => { + fs.readFile(path, {encoding: 'utf8'}, (err, data) => { + if (err) return resolve(def); + + let unserialized; + try { unserialized = JSON.parse(data); } + catch (e) { unserialized = def; } + + resolve(unserialized); + }); + }); + } + + get(clone) { + if (clone == null) clone = this.useCloneByDefault; + + return this.load.then(data => { + if (clone) return JSON.parse(JSON.stringify(data)); + else return data; + }); + } + + save(newData) { + const serialized = JSON.stringify(newData); + this.load = new Promise((resolve, reject) => { + fs.writeFile(this.path, serialized, {encoding: 'utf8'}, () => { + resolve(newData); + }); + }); + + return this.get(); + } +} + +function getDb(dbName, def) { + if (! databases[dbName]) { + const dbPath = path.resolve(dbDir, `${dbName}.json`); + databases[dbName] = new JSONDB(dbPath, def); + } + + return databases[dbName]; +} + +function get(dbName, def) { + return getDb(dbName, def).get(); +} + +function save(dbName, data) { + return getDb(dbName, data).save(data); +} + +module.exports = { + get, + save, +}; diff --git a/src/logs.js b/src/logs.js new file mode 100644 index 0000000..fa62ec6 --- /dev/null +++ b/src/logs.js @@ -0,0 +1,108 @@ +const fs = require('fs'); +const crypto = require('crypto'); +const moment = require('moment'); +const config = require('../config'); + +const logDir = config.logDir || `${__dirname}/logs`; +const logFileFormatRegex = /^([0-9\-]+?)__([0-9]+?)__([0-9a-f]+?)\.txt$/; + +function getLogFileInfo(logFilename) { + const match = logFilename.match(logFileFormatRegex); + if (! match) return null; + + const date = moment.utc(match[1], 'YYYY-MM-DD-HH-mm-ss').format('YYYY-MM-DD HH:mm:ss'); + + return { + filename: logFilename, + date: date, + userId: match[2], + token: match[3], + }; +} + +function getLogFilePath(logFilename) { + return `${logDir}/${logFilename}`; +} + +function getLogFileUrl(logFilename) { + const info = getLogFileInfo(logFilename); + return utils.getSelfUrl(`logs/${info.token}`); +} + +function getNewLogFile(userId) { + return new Promise(resolve => { + crypto.randomBytes(16, (err, buf) => { + const token = buf.toString('hex'); + const date = moment.utc().format('YYYY-MM-DD-HH-mm-ss'); + + resolve(`${date}__${userId}__${token}.txt`); + }); + }); +} + +function findLogFile(token) { + return new Promise(resolve => { + fs.readdir(logDir, (err, files) => { + for (const file of files) { + if (file.endsWith(`__${token}.txt`)) { + resolve(file); + return; + } + } + + resolve(null); + }); + }); +} + +function getLogsByUserId(userId) { + return new Promise(resolve => { + fs.readdir(logDir, (err, files) => { + const logfileInfos = files + .map(file => getLogFileInfo(file)) + .filter(info => info && info.userId === userId); + + resolve(logfileInfos); + }); + }); +} + +function getLogsWithUrlByUserId(userId) { + return getLogsByUserId(userId).then(infos => { + const urlPromises = infos.map(info => { + return getLogFileUrl(info.filename).then(url => { + info.url = url; + return info; + }); + }); + + return Promise.all(urlPromises).then(infos => { + infos.sort((a, b) => { + if (a.date > b.date) return 1; + if (a.date < b.date) return -1; + return 0; + }); + + return infos; + }); + }); +} + +function saveLogFile(logFilename, content) { + return new Promise((resolve, reject) => { + fs.writeFile(getLogFilePath(logFilename), content, {encoding: 'utf8'}, err => { + if (err) return reject(err); + resolve(); + }); + }); +} + +module.exports = { + getLogFileInfo, + getLogFilePath, + getNewLogFile, + findLogFile, + getLogsByUserId, + getLogsWithUrlByUserId, + saveLogFile, +}; diff --git a/queue.js b/src/queue.js similarity index 56% rename from queue.js rename to src/queue.js index aa63bc5..32dba74 100644 --- a/queue.js +++ b/src/queue.js @@ -6,7 +6,7 @@ class Queue { add(fn) { this.queue.push(fn); - if (!this.running) this.next(); + if (! this.running) this.next(); } next() { @@ -18,7 +18,11 @@ class Queue { } const fn = this.queue.shift(); - Promise.resolve(fn()).then(() => this.next()); + new Promise(resolve => { + // Either fn() completes or the timeout of 10sec is reached + Promise.resolve(fn()).then(resolve); + setTimeout(resolve, 10000); + }).then(() => this.next()); } } diff --git a/src/threads.js b/src/threads.js new file mode 100644 index 0000000..e93ba2d --- /dev/null +++ b/src/threads.js @@ -0,0 +1,80 @@ +const Eris = require('eris'); +const utils = require('./utils'); +const jsonDb = require('./jsonDb'); + +/** + * @typedef {Object} ModMailThread + * @property {String} channelId + * @property {String} userId + * @property {String} username + * @property {Boolean} _wasCreated + */ + +/** + * Returns information about the modmail thread channel for the given user. We can't return channel objects + * directly since they're not always available immediately after creation. + * @param {Eris.Client} bot + * @param {Eris.User} user + * @param {Boolean} allowCreate + * @returns {Promise} + */ +function getForUser(bot, user, allowCreate = true) { + return jsonDb.get('threads').then(threads => { + const thread = threads.find(t => t.userId === user.id); + if (thread) return thread; + + // If we didn't find an existing modmail thread, attempt creating one + if (! allowCreate) return null; + + // Channel names are particularly picky about what characters they allow... + let cleanName = user.username.replace(/[^a-zA-Z0-9]/ig, '').toLowerCase().trim(); + if (cleanName === '') cleanName = 'unknown'; + + const channelName = `${cleanName}-${user.discriminator}`; + console.log(`[NOTE] Creating new thread channel ${channelName}`); + + return utils.getModmailGuild(bot).createChannel(`${channelName}`) + .then(channel => { + const thread = { + channelId: channel.id, + userId: user.id, + username: `${user.username}#${user.discriminator}`, + }; + + const threads = jsonDb.get('threads'); + threads.push(thread); + jsonDb.save('threads', threads); + + thread._wasCreated = true; + return thread; + }, err => { + console.error(`Error creating modmail channel for ${user.username}#${user.discriminator}!`); + }); + }); +} + +/** + * @param {String} channelId + * @returns {Promise} + */ +function getByChannelId(channelId) { + return jsonDb.get('threads').then(threads => { + return threads.find(t => t.userId === user.id); + }); +} + +function close(channelId) { + return jsonDb.get('threads').then(threads => { + const thread = threads.find(t => t.userId === user.id); + if (! thread) return; + + threads.splice(threads.indexOf(thread), 1); + return jsonDb.save('threads', threads); + }); +} + +module.exports = { + getForUser, + getByChannelId, + close, +}; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..dcb9ba6 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,75 @@ +const moment = require('moment'); +const publicIp = require('public-ip'); +const config = require('../config'); +const utils = require('./utils'); + +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; + }); +} + +const userMentionRegex = /^<@\!?([0-9]+?)>$/; + +function getUserMention(str) { + str = str.trim(); + + if (str.match(/^[0-9]+$/)) { + // User ID + return str; + } else { + let mentionMatch = str.match(userMentionRegex); + if (mentionMatch) return mentionMatch[1]; + } + + return null; +} + +function getTimestamp(date) { + return moment.utc(date).format('HH:mm'); +} + +function disableLinkPreviews(str) { + return str.replace(/(^|[^<])(https?:\/\/\S+)/ig, '$1<$2>'); +} + +function getSelfUrl(path) { + if (config.url) { + return Promise.resolve(`${config.url}/${path}`); + } else { + return publicIp.v4().then(ip => { + return `http://${ip}:${logServerPort}/${path}`; + }); + } +} + +module.exports = { + getModmailGuild, + formatAttachment, + formatUserDM, + getUserMention, + getTimestamp, + disableLinkPreviews, + getSelfUrl, +}; diff --git a/src/webserver.js b/src/webserver.js new file mode 100644 index 0000000..636540e --- /dev/null +++ b/src/webserver.js @@ -0,0 +1,67 @@ +const http = require('http'); +const mime = require('mime'); +const config = require('../config'); + +const port = config.port || 8890; + +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(); + + fs.readFile(getLogFilePath(logfile), {encoding: 'utf8'}, (err, data) => { + if (err) { + res.statusCode = 404; + res.end('Log not found'); + return; + } + + res.setHeader('Content-Type', 'text/plain; charset=UTF-8'); + res.end(data); + }); + }); +} + +function serveAttachments(res, pathParts) { + const desiredFilename = pathParts[pathParts.length - 1]; + const id = pathParts[pathParts.length - 2]; + + if (id.match(/^[0-9]+$/) === null) return res.end(); + if (desiredFilename.match(/^[0-9a-z\._-]+$/i) === null) return res.end(); + + const attachmentPath = getAttachmentPath(id); + fs.access(attachmentPath, (err) => { + if (err) { + res.statusCode = 404; + res.end('Attachment not found'); + return; + } + + const filenameParts = desiredFilename.split('.'); + const ext = (filenameParts.length > 1 ? filenameParts[filenameParts.length - 1] : 'bin'); + const fileMime = mime.lookup(ext); + + res.setHeader('Content-Type', fileMime); + + const read = fs.createReadStream(attachmentPath); + read.pipe(res); + }) +} + +function run() { + const server = http.createServer((req, res) => { + const parsedUrl = url.parse(`http://${req.url}`); + const pathParts = parsedUrl.path.split('/').filter(v => v !== ''); + + if (parsedUrl.path.startsWith('/logs/')) serveLogs(res, pathParts); + if (parsedUrl.path.startsWith('/attachments/')) serveAttachments(res, pathParts); + }); + + server.listen(logServerPort); +} + +module.exports = { + run, +};