diff --git a/CHANGELOG.md b/CHANGELOG.md index 1008fd3..da35c89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Sep 19, 2017 +* Added `logChannelId` option +* Some code clean-up. Please open an issue if you encounter any bugs! +* The bot now throws an error for unknown options in `config.json` (assuming they're typos) and tells you if you haven't configured the token or mail guild id. + ## Aug 3, 2017 * Fixed user nicknames not showing in new threads * The "manageRoles" permission is no longer required to use commands on the inbox server. diff --git a/package.json b/package.json index 5a8ca07..3a1dcd1 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,9 @@ "license": "MIT", "main": "src/index.js", "scripts": { - "start": "node src/index.js", - "test": "echo \"Error: no test specified\" && exit 1" + "start": "node --trace-warnings src/index.js", + "test": "echo \"Error: no test specified\" && exit 1", + "lint": "./node_modules/.bin/eslint ./src" }, "author": "", "dependencies": { diff --git a/src/attachments.js b/src/attachments.js index fbad2a4..029bdaf 100644 --- a/src/attachments.js +++ b/src/attachments.js @@ -1,8 +1,9 @@ const Eris = require('eris'); const fs = require('fs'); const https = require('https'); -const config = require('../config'); -const utils = require('./utils'); +const config = require('./config'); + +const getUtils = () => require('./utils'); const attachmentDir = config.attachmentDir || `${__dirname}/../attachments`; @@ -64,7 +65,7 @@ function saveAttachmentsInMessage(msg) { */ function getUrl(attachmentId, desiredName = null) { if (desiredName == null) desiredName = 'file.bin'; - return utils.getSelfUrl(`attachments/${attachmentId}/${desiredName}`); + return getUtils().getSelfUrl(`attachments/${attachmentId}/${desiredName}`); } module.exports = { diff --git a/src/bot.js b/src/bot.js new file mode 100644 index 0000000..5dedf02 --- /dev/null +++ b/src/bot.js @@ -0,0 +1,15 @@ +const Eris = require('eris'); +const config = require('./config'); + +const bot = new Eris.CommandClient(config.token, {}, { + prefix: config.prefix, + ignoreSelf: true, + ignoreBots: true, + defaultHelpCommand: false, + getAllUsers: true, + defaultCommandOptions: { + caseInsensitive: true, + }, +}); + +module.exports = bot; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..6a2e835 --- /dev/null +++ b/src/config.js @@ -0,0 +1,49 @@ +let userConfig; + +try { + userConfig = require('../config'); +} catch (e) { + throw new Error(`Config file could not be found or read! The error given was: ${e.message}`); +} + +const defaultConfig = { + "token": null, + "mailGuildId": null, + "mainGuildId": null, + "logChannelId": null, + + "prefix": "!", + "snippetPrefix": "!!", + + "status": "Message me for help!", + "responseMessage": "Thank you for your message! Our mod team will reply to you here as soon as possible.", + + "inboxServerPermission": null, + "alwaysReply": false, + "alwaysReplyAnon": false, + "useNicknames": false, + "ignoreAccidentalThreads": false, + + "enableGreeting": false, + "greetingMessage": "The message the bot sends to a new user", + "greetingAttachment": "Put a file path here", + + "port": 8890, + "url": null +}; + +const finalConfig = Object.assign({}, defaultConfig); + +for (const [prop, value] of Object.entries(userConfig)) { + if (! defaultConfig.hasOwnProperty(prop)) { + throw new Error(`Invalid option: ${prop}`); + } + + finalConfig[prop] = value; +} + +if (! finalConfig.token) throw new Error('Missing token!'); +if (! finalConfig.mailGuildId) throw new Error('Missing mailGuildId (inbox server id)!'); +if (! finalConfig.mainGuildId) throw new Error('Missing mainGuildId!'); + +module.exports = finalConfig; diff --git a/src/greeting.js b/src/greeting.js index 53b1f8e..f1f50ad 100644 --- a/src/greeting.js +++ b/src/greeting.js @@ -1,6 +1,6 @@ const path = require('path'); const fs = require('fs'); -const config = require('../config'); +const config = require('./config'); const greetingGuildId = config.mainGuildId || config.greetingGuildId; diff --git a/src/index.js b/src/index.js index c246211..f2b047f 100644 --- a/src/index.js +++ b/src/index.js @@ -1,8 +1,10 @@ -const Eris = require('eris'); const fs = require('fs'); +const Eris = require('eris'); const moment = require('moment'); const humanizeDuration = require('humanize-duration'); -const config = require('../config'); + +const config = require('./config'); +const bot = require('./bot'); const Queue = require('./queue'); const utils = require('./utils'); const blocked = require('./blocked'); @@ -13,281 +15,234 @@ const snippets = require('./snippets'); const webserver = require('./webserver'); const greeting = require('./greeting'); -const prefix = config.prefix || '!'; -const snippetPrefix = config.snippetPrefix || prefix.repeat(2); - -const bot = new Eris.CommandClient(config.token, {}, { - prefix: prefix, - ignoreSelf: true, - ignoreBots: true, - defaultHelpCommand: false, - getAllUsers: true, - defaultCommandOptions: { - caseInsensitive: true, - }, -}); - const messageQueue = new Queue(); +// Force crash on unhandled rejections (use something like forever/pm2 to restart) +process.on('unhandledRejection', err => { + if (err instanceof utils.BotError) { + // BotErrors don't need a stack trace + console.error(`Error: ${err.message}`); + } else { + console.error(err); + } + + process.exit(1); +}); + +// Once the bot has connected, set the status/"playing" message bot.on('ready', () => { bot.editStatus(null, {name: config.status || 'Message me for help'}); console.log('Bot started, listening to DMs'); }); -function isStaff(member) { - if (! config.inboxServerPermission) return true; - return member.permission.has(config.inboxServerPermission); -} - -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}`; - }); -} - -// If the alwaysReply option is set to true, send all messages in modmail threads as replies, unless they start with the prefix +// If the alwaysReply option is set to true, send all messages in modmail threads as replies, unless they start with a command prefix if (config.alwaysReply) { bot.on('messageCreate', msg => { - if (! msg.channel.guild) return; - if (msg.channel.guild.id !== utils.getInboxGuild(bot).id) return; - if (! isStaff(msg.member)) return; + if (! utils.messageIsOnInboxServer(msg)) return; + if (! utils.isStaff(msg)) return; if (msg.author.bot) return; - if (msg.content[0] == bot.commandOptions.prefix) return; + if (msg.content.startsWith(config.prefix) || msg.content.startsWith(config.snippetPrefix)) return; reply(msg, msg.content.trim(), config.alwaysReplyAnon || false); }); } // "Bot was mentioned in #general-discussion" -bot.on('messageCreate', msg => { - if (msg.author.id === bot.user.id) return; +bot.on('messageCreate', async msg => { + if (! utils.messageIsOnMainServer(msg)) return; + if (! msg.mentions.some(user => user.id === bot.user.id)) return; - if (msg.mentions.some(user => user.id === bot.user.id)) { - // If the person who mentioned the modmail bot is on the modmail server, don't ping about it - if (utils.getInboxGuild(bot).members.get(msg.author.id)) return; + // If the person who mentioned the modmail bot is also on the modmail server, ignore them + if (utils.getInboxGuild().members.get(msg.author.id)) return; - blocked.isBlocked(msg.author.id).then(isBlocked => { - if (isBlocked) return; + // If the person who mentioned the bot is blocked, ignore them + if (await blocked.isBlocked(msg.author.id)) return; - bot.createMessage(utils.getLogChannel(bot).id, { - content: `@here Bot mentioned in ${msg.channel.mention} by **${msg.author.username}#${msg.author.discriminator}**: "${msg.cleanContent}"`, - disableEveryone: false, - }); - }); - } + bot.createMessage(utils.getLogChannel(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) => { +bot.on('messageCreate', async 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; + if (await blocked.isBlocked(msg.author.id)) return; - // Download and save copies of attachments in the background - const attachmentSavePromise = attachments.saveAttachmentsInMessage(msg); + // Download and save copies of attachments in the background + const attachmentSavePromise = attachments.saveAttachmentsInMessage(msg); - let thread, userLogs; - let threadCreationFailed = false; + let threadCreationFailed = false; - // Private message handling is queued so e.g. multiple message in quick succession don't result in multiple channels being created - messageQueue.add(() => { - return threads.getForUser(bot, msg.author, true, msg) - .then(userThread => { - thread = userThread; - return logs.getLogsByUserId(msg.author.id); - }, err => { - console.log(`[ERROR] Modmail channel for ${msg.author.username}#${msg.author.discriminator} could not be created:\n${err.message}`); - threadCreationFailed = true; - }) - .then(foundUserLogs => { - userLogs = foundUserLogs; - }) - .then(() => { - let content = msg.content; + // Private message handling is queued so e.g. multiple message in quick succession don't result in multiple channels being created + messageQueue.add(async () => { + let thread; - if (threadCreationFailed) { - // If the thread could not be created, send a warning about this to all mods so they can DM the user directly instead - let warningMessage = ` -@here Error creating modmail thread for ${msg.author.username}#${msg.author.discriminator} (${msg.author.id})! + // Find the corresponding modmail thread + try { + thread = await threads.getForUser(msg.author, true, msg); + } catch (e) { + console.error(e); + utils.postError(` +Modmail thread for ${msg.author.username}#${msg.author.discriminator} (${msg.author.id}) could not be created: +\`\`\`${e.message}\`\`\` Here's what their message contained: -\`\`\`${content}\`\`\` - `.trim(); +\`\`\`${msg.cleanContent}\`\`\``); + return; + } - bot.createMessage(utils.getLogChannel(bot).id, { - content: warningMessage, - disableEveryone: false, - }); + if (! thread) { + utils.postError(` +Modmail thread for ${msg.author.username}#${msg.author.discriminator} (${msg.author.id}) was not found. - return; - } else if (! thread) { - // No thread but creation didn't fail either -> probably ignored - return; - } +Here's what their message contained: +\`\`\`${msg.cleanContent}\`\`\` +`); + } - let threadInitDonePromise = Promise.resolve(); + if (thread._wasCreated) { + const mainGuild = utils.getMainGuild(); + const member = (mainGuild ? mainGuild.members.get(msg.author.id) : null); + if (! member) console.log(`[INFO] Member ${msg.author.id} not found in main guild ${config.mainGuildId}`); - // If the thread was just created, do some extra stuff - if (thread._wasCreated) { - const member = utils.getMainGuild(bot).members.get(msg.author.id); + let mainGuildNickname = null; + if (member && member.nick) mainGuildNickname = member.nick; + else if (member && member.user) mainGuildNickname = member.user.username; + else if (member == null) mainGuildNickname = 'NOT ON SERVER'; - if (! member) { - console.log(`Member ${msg.author.id} not found in main guild ${config.mainGuildId}`); - } + if (mainGuildNickname == null) mainGuildNickname = 'UNKNOWN'; - let mainGuildNickname = null; - if (member && member.nick) mainGuildNickname = member.nick; - else if (member && member.user) mainGuildNickname = member.user.username; - else if (member == null) mainGuildNickname = 'NOT ON SERVER'; + const userLogs = await logs.getLogsByUserId(msg.author.id); + const accountAge = humanizeDuration(Date.now() - msg.author.createdAt, {largest: 2}); + const infoHeader = `ACCOUNT AGE **${accountAge}**, ID **${msg.author.id}**, NICKNAME **${mainGuildNickname}**, LOGS **${userLogs.length}**\n-------------------------------`; - if (mainGuildNickname == null) mainGuildNickname = 'UNKNOWN'; + await bot.createMessage(thread.channelId, infoHeader); - const accountAge = humanizeDuration(Date.now() - msg.author.createdAt, {largest: 2}); - const infoHeader = `ACCOUNT AGE **${accountAge}**, ID **${msg.author.id}**, NICKNAME **${mainGuildNickname}**, LOGS **${userLogs.length}**\n-------------------------------`; + // Ping mods of the new thread + await bot.createMessage(thread.channelId, { + content: `@here New modmail thread (${msg.author.username}#${msg.author.discriminator})`, + disableEveryone: false, + }); - threadInitDonePromise = bot.createMessage(thread.channelId, infoHeader).then(() => { - // Ping mods of the new thread - return bot.createMessage(thread.channelId, { - content: `@here New modmail thread (${msg.author.username}#${msg.author.discriminator})`, - disableEveryone: false, - }); - }); + // Send an automatic reply to the user informing them of the successfully created modmail thread + msg.channel.createMessage(config.responseMessage).catch(err => { + utils.postError(`There is an issue sending messages to ${msg.author.username}#${msg.author.discriminator} (${msg.author.id}); consider messaging manually`); + }); + } - // Send an automatic reply to the user informing them of the successfully created modmail thread - msg.channel.createMessage(config.responseMessage || "Thank you for your message! Our mod team will reply to you here as soon as possible.").then(null, (err) => { - bot.createMessage(utils.getLogChannel(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(); + const attachmentsPendingStr = '\n\n*Attachments pending...*'; - const attachmentsPendingStr = '\n\n*Attachments pending...*'; - if (msg.attachments.length > 0) content += attachmentsPendingStr; + let content = msg.content; + if (msg.attachments.length > 0) content += attachmentsPendingStr; - threadInitDonePromise.then(() => { - const timestamp = utils.getTimestamp(); - bot.createMessage(thread.channelId, `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${content}`).then(createdMsg => { - if (msg.attachments.length === 0) return; + const createdMsg = await bot.createMessage(thread.channelId, `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${content}`); - // Once attachments have been saved, add links to them to the message - attachmentSavePromise.then(() => { - const attachmentFormatPromises = msg.attachments.map(formatAttachment); - Promise.all(attachmentFormatPromises).then(formattedAttachments => { - let attachmentMsg = ''; - - formattedAttachments.forEach(str => { - attachmentMsg += `\n\n${str}`; - }); - - createdMsg.edit(createdMsg.content.replace(attachmentsPendingStr, attachmentMsg)); - }); - }); - }); - }); - }); - }); + if (msg.attachments.length > 0) { + await attachmentSavePromise; + const formattedAttachments = await Promise.all(msg.attachments.map(utils.formatAttachment)); + const attachmentMsg = `\n\n` + formattedAttachments.reduce((str, formatted) => str + `\n\n${formatted}`); + createdMsg.edit(createdMsg.content.replace(attachmentsPendingStr, attachmentMsg)); + } }); }); // Edits in DMs -bot.on('messageUpdate', (msg, oldMessage) => { +bot.on('messageUpdate', async (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; + if (await blocked.isBlocked(msg.author.id)) return; - let oldContent = oldMessage.content; - const newContent = msg.content; + let oldContent = oldMessage.content; + const newContent = msg.content; - if (oldContent == null) oldContent = '*Unavailable due to bot restart*'; + // Old message content doesn't persist between bot restarts + if (oldContent == null) oldContent = '*Unavailable due to bot restart*'; - // Ignore bogus edit events with no changes - if (newContent.trim() === oldContent.trim()) return; + // Ignore bogus edit events with no changes + if (newContent.trim() === oldContent.trim()) return; - threads.getForUser(bot, msg.author).then(thread => { - if (! thread) return; + const thread = await threads.getForUser(msg.author); + if (! thread) return; - const editMessage = utils.disableLinkPreviews(`**The user edited their message:**\n\`B:\` ${oldContent}\n\`A:\` ${newContent}`); - - bot.createMessage(thread.channelId, editMessage); - }); - }); + const editMessage = utils.disableLinkPreviews(`**The user edited their message:**\n\`B:\` ${oldContent}\n\`A:\` ${newContent}`); + bot.createMessage(thread.channelId, editMessage); }); -function reply(msg, text, anonymous = false) { - threads.getByChannelId(msg.channel.id).then(thread => { - if (! thread) return; +/** + * Sends a reply to the modmail thread where `msg` was posted. + * @param {Eris.Message} msg + * @param {string} text + * @param {bool} anonymous + * @returns {Promise} + */ +async function reply(msg, text, anonymous = false) { + const thread = await threads.getByChannelId(msg.channel.id); + if (! thread) return; - attachments.saveAttachmentsInMessage(msg).then(() => { - bot.getDMChannel(thread.userId).then(dmChannel => { - let modUsername, logModUsername; - const mainRole = utils.getMainRole(msg.member); + await attachments.saveAttachmentsInMessage(msg); - if (anonymous) { - modUsername = (mainRole ? mainRole.name : 'Moderator'); - logModUsername = `(Anonymous) (${msg.author.username}) ${mainRole ? mainRole.name : 'Moderator'}`; - } else { - const name = (config.useNicknames ? msg.member.nick || msg.author.username : msg.author.username); - modUsername = (mainRole ? `(${mainRole.name}) ${name}` : name); - logModUsername = modUsername; - } + const dmChannel = await bot.getDMChannel(thread.userId); - let content = `**${modUsername}:** ${text}`; - let logContent = `**${logModUsername}:** ${text}`; + let modUsername, logModUsername; + const mainRole = utils.getMainRole(msg.member); - function sendMessage(file, attachmentUrl) { - dmChannel.createMessage(content, file).then(() => { - if (attachmentUrl) { - content += `\n\n**Attachment:** ${attachmentUrl}`; - logContent += `\n\n**Attachment:** ${attachmentUrl}`; - } + if (anonymous) { + modUsername = (mainRole ? mainRole.name : 'Moderator'); + logModUsername = `(Anonymous) (${msg.author.username}) ${mainRole ? mainRole.name : 'Moderator'}`; + } else { + const name = (config.useNicknames ? msg.member.nick || msg.author.username : msg.author.username); + modUsername = (mainRole ? `(${mainRole.name}) ${name}` : name); + logModUsername = modUsername; + } - // Show the message in the modmail thread as well - const timestamp = utils.getTimestamp(); - msg.channel.createMessage(`[${timestamp}] » ${logContent}`); - }, (err) => { - if (err.resp && err.resp.statusCode === 403) { - msg.channel.createMessage(`Could not send reply; the user has likely left the server or 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()}`); - } - }); + let content = `**${modUsername}:** ${text}`; + let logContent = `**${logModUsername}:** ${text}`; - msg.delete(); - }; + async function sendMessage(file, attachmentUrl) { + try { + await dmChannel.createMessage(content, file); + } catch (e) { + if (e.resp && e.resp.statusCode === 403) { + msg.channel.createMessage(`Could not send reply; the user has likely left the server or blocked the bot`); + } else if (e.resp) { + msg.channel.createMessage(`Could not send reply; error code ${e.resp.statusCode}`); + } else { + msg.channel.createMessage(`Could not send reply: ${e.toString()}`); + } + } - // If the reply has an attachment, relay it as is - if (msg.attachments.length > 0) { - fs.readFile(attachments.getPath(msg.attachments[0].id), (err, data) => { - const file = {file: data, name: msg.attachments[0].filename}; + if (attachmentUrl) { + content += `\n\n**Attachment:** ${attachmentUrl}`; + logContent += `\n\n**Attachment:** ${attachmentUrl}`; + } - attachments.getUrl(msg.attachments[0].id, msg.attachments[0].filename).then(attachmentUrl => { - sendMessage(file, attachmentUrl); - }); - }); - } else { - sendMessage(); - } - }); + // Show the message in the modmail thread as well + msg.channel.createMessage(`[${utils.getTimestamp()}] » ${logContent}`); + msg.delete(); + }; + + if (msg.attachments.length > 0) { + // If the reply has an attachment, relay it as is + fs.readFile(attachments.getPath(msg.attachments[0].id), async (err, data) => { + const file = {file: data, name: msg.attachments[0].filename}; + + const attachmentUrl = await attachments.getUrl(msg.attachments[0].id, msg.attachments[0].filename); + sendMessage(file, attachmentUrl); }); - }); + } else { + // Otherwise just send the message regularly + sendMessage(); + } } // 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.getInboxGuild(bot).id) return; - if (! isStaff(msg.member)) return; - +utils.addInboxCommand('reply', (msg, args) => { const text = args.join(' ').trim(); reply(msg, text, false); }); @@ -295,143 +250,116 @@ bot.registerCommand('reply', (msg, args) => { bot.registerCommandAlias('r', 'reply'); // Anonymous replies only show the role, not the username -bot.registerCommand('anonreply', (msg, args) => { - if (! msg.channel.guild) return; - if (msg.channel.guild.id !== utils.getInboxGuild(bot).id) return; - if (! isStaff(msg.member)) return; - +utils.addInboxCommand('anonreply', (msg, args) => { const text = args.join(' ').trim(); reply(msg, text, true); }); bot.registerCommandAlias('ar', 'anonreply'); -bot.registerCommand('close', (msg, args) => { - if (! msg.channel.guild) return; - if (msg.channel.guild.id !== utils.getInboxGuild(bot).id) return; - if (! isStaff(msg.member)) return; +// Close a thread. Closing a thread saves a log of the channel's contents and then deletes the channel. +utils.addInboxCommand('close', async (msg, args, thread) => { + if (! thread) return; - threads.getByChannelId(msg.channel.id).then(thread => { - if (! thread) return; + await msg.channel.createMessage('Saving logs and closing channel...'); - 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'; + const logMessages = await msg.channel.getMessages(10000); + const log = logMessages.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(() => logs.getLogFileUrl(logFilename)) - .then(url => { - const closeMessage = `Modmail thread with ${thread.username} (${thread.userId}) was closed by ${msg.author.username} -Logs: <${url}>`; + const logFilename = await logs.getNewLogFile(thread.userId); + await logs.saveLogFile(logFilename, log); - bot.createMessage(utils.getLogChannel(bot).id, closeMessage); - threads.close(thread.channelId).then(() => msg.channel.delete()); - }); - }); - }); - }); + const logUrl = await logs.getLogFileUrl(logFilename); + const closeMessage = `Modmail thread with ${thread.username} (${thread.userId}) was closed by ${msg.author.username} +Logs: <${logUrl}>`; + + bot.createMessage(utils.getLogChannel(bot).id, closeMessage); + await threads.close(thread.channelId); + msg.channel.delete(); }); -bot.registerCommand('block', (msg, args) => { - if (! msg.channel.guild) return; - if (msg.channel.guild.id !== utils.getInboxGuild(bot).id) return; - if (! isStaff(msg.member)) return; - - function block(userId) { - blocked.block(userId).then(() => { - msg.channel.createMessage(`Blocked <@${userId}> (id ${userId}) from modmail`); - }); +utils.addInboxCommand('block', (msg, args, thread) => { + async function block(userId) { + await blocked.block(userId); + msg.channel.createMessage(`Blocked <@${userId}> (id ${userId}) from modmail`); } if (args.length > 0) { + // User mention/id as argument const userId = utils.getUserMention(args.join(' ')); if (! userId) return; block(userId); - } else { + } else if (thread) { // 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(thread.userId); - }); + block(thread.userId); } }); -bot.registerCommand('unblock', (msg, args) => { - if (! msg.channel.guild) return; - if (msg.channel.guild.id !== utils.getInboxGuild(bot).id) return; - if (! isStaff(msg.member)) return; - - function unblock(userId) { - blocked.unblock(userId).then(() => { - msg.channel.createMessage(`Unblocked <@${userId}> (id ${userId}) from modmail`); - }); +utils.addInboxCommand('unblock', (msg, args, thread) => { + async function unblock(userId) { + await blocked.unblock(userId); + msg.channel.createMessage(`Unblocked <@${userId}> (id ${userId}) from modmail`); } if (args.length > 0) { + // User mention/id as argument const userId = utils.getUserMention(args.join(' ')); if (! userId) return; unblock(userId); - } else { + } else if (thread) { // 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(thread.userId); - }); + unblock(thread.userId); } }); -bot.registerCommand('logs', (msg, args) => { - if (! msg.channel.guild) return; - if (msg.channel.guild.id !== utils.getInboxGuild(bot).id) return; - if (! isStaff(msg.member)) return; +utils.addInboxCommand('logs', (msg, args, thread) => { + async function getLogs(userId) { + const infos = await logs.getLogsWithUrlByUserId(userId); + let message = `**Log files for <@${userId}>:**\n`; - function getLogs(userId) { - logs.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'); - 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'); + // Send the list of logs in chunks of 15 lines per message + const lines = message.split('\n'); + const chunks = utils.chunk(lines, 15); - // Send the list of logs in chunks of 15 lines per message - const lines = message.split('\n'); - const chunks = utils.chunk(lines, 15); - - let root = Promise.resolve(); - chunks.forEach(lines => { - root = root.then(() => msg.channel.createMessage(lines.join('\n'))); - }); + let root = Promise.resolve(); + chunks.forEach(lines => { + root = root.then(() => msg.channel.createMessage(lines.join('\n'))); }); } if (args.length > 0) { + // User mention/id as argument const userId = utils.getUserMention(args.join(' ')); if (! userId) return; getLogs(userId); - } else { + } else if (thread) { // 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(thread.userId); - }); + getLogs(thread.userId); } }); +/** + * ---!!!!!!!!!!!--- REFACTOR LINE ---!!!!!!!!!!!--- + */ + // Snippets bot.on('messageCreate', async msg => { - if (! msg.channel.guild) return; - if (msg.channel.guild.id !== utils.getInboxGuild(bot).id) return; - if (! isStaff(msg.member)) return; + if (! utils.messageIsOnInboxServer(msg)) return; + if (! utils.isStaff(msg.member)) return; + if (msg.author.bot) return; if (! msg.content) return; - if (! msg.content.startsWith(snippetPrefix)) return; + if (! msg.content.startsWith(config.snippetPrefix)) return; - const shortcut = msg.content.replace(snippetPrefix, '').toLowerCase(); + const shortcut = msg.content.replace(config.snippetPrefix, '').toLowerCase(); const snippet = await snippets.get(shortcut); if (! snippet) return; @@ -439,24 +367,20 @@ bot.on('messageCreate', async msg => { }); // Show or add a snippet -bot.registerCommand('snippet', async (msg, args) => { - if (! msg.channel.guild) return; - if (msg.channel.guild.id !== utils.getInboxGuild(bot).id) return; - if (! isStaff(msg.member)) return; - +utils.addInboxCommand('snippet', async (msg, args) => { const shortcut = args[0]; + if (! shortcut) return + const text = args.slice(1).join(' ').trim(); - - if (! shortcut) return; - const snippet = await snippets.get(shortcut); + if (snippet) { if (text) { // If the snippet exists and we're trying to create a new one, inform the user the snippet already exists msg.channel.createMessage(`Snippet "${shortcut}" already exists! You can edit or delete it with ${prefix}edit_snippet and ${prefix}delete_snippet respectively.`); } else { // If the snippet exists and we're NOT trying to create a new one, show info about the existing snippet - msg.channel.createMessage(`\`${snippetPrefix}${shortcut}\` replies ${snippet.isAnonymous ? 'anonymously ' : ''}with:\n${snippet.text}`); + msg.channel.createMessage(`\`${config.snippetPrefix}${shortcut}\` replies ${snippet.isAnonymous ? 'anonymously ' : ''}with:\n${snippet.text}`); } } else { if (text) { @@ -466,17 +390,13 @@ bot.registerCommand('snippet', async (msg, args) => { } else { // If the snippet doesn't exist and the user isn't trying to create it, inform them how to create it msg.channel.createMessage(`Snippet "${shortcut}" doesn't exist! You can create it with \`${prefix}snippet ${shortcut} text\``); - return; } } }); + bot.registerCommandAlias('s', 'snippet'); -bot.registerCommand('delete_snippet', async (msg, args) => { - if (! msg.channel.guild) return; - if (msg.channel.guild.id !== utils.getInboxGuild(bot).id) return; - if (! isStaff(msg.member)) return; - +utils.addInboxCommand('delete_snippet', async (msg, args) => { const shortcut = args[0]; if (! shortcut) return; @@ -489,17 +409,14 @@ bot.registerCommand('delete_snippet', async (msg, args) => { await snippets.del(shortcut); msg.channel.createMessage(`Snippet "${shortcut}" deleted!`); }); + bot.registerCommandAlias('ds', 'delete_snippet'); -bot.registerCommand('edit_snippet', async (msg, args) => { - if (! msg.channel.guild) return; - if (msg.channel.guild.id !== utils.getInboxGuild(bot).id) return; - if (! isStaff(msg.member)) return; - +utils.addInboxCommand('edit_snippet', async (msg, args) => { const shortcut = args[0]; - const text = args.slice(1).join(' ').trim(); - if (! shortcut) return; + + const text = args.slice(1).join(' ').trim(); if (! text) return; const snippet = await snippets.get(shortcut); @@ -513,20 +430,18 @@ bot.registerCommand('edit_snippet', async (msg, args) => { msg.channel.createMessage(`Snippet "${shortcut}" edited!`); }); + bot.registerCommandAlias('es', 'edit_snippet'); -bot.registerCommand('snippets', async msg => { - if (! msg.channel.guild) return; - if (msg.channel.guild.id !== utils.getInboxGuild(bot).id) return; - if (! isStaff(msg.member)) return; - +utils.addInboxCommand('snippets', async msg => { const allSnippets = await snippets.all(); const shortcuts = Object.keys(allSnippets); shortcuts.sort(); - msg.channel.createMessage(`Available snippets (prefix ${snippetPrefix}):\n${shortcuts.join(', ')}`); + msg.channel.createMessage(`Available snippets (prefix ${config.snippetPrefix}):\n${shortcuts.join(', ')}`); }); +// Start the bot! bot.connect(); webserver.run(); greeting.init(bot); diff --git a/src/jsonDb.js b/src/jsonDb.js index 5427a16..bc42182 100644 --- a/src/jsonDb.js +++ b/src/jsonDb.js @@ -1,6 +1,6 @@ const fs = require('fs'); const path = require('path'); -const config = require('../config'); +const config = require('./config'); const dbDir = config.dbDir || `${__dirname}/../db`; diff --git a/src/logs.js b/src/logs.js index c72c9e1..b6a3012 100644 --- a/src/logs.js +++ b/src/logs.js @@ -1,8 +1,9 @@ const fs = require('fs'); const crypto = require('crypto'); const moment = require('moment'); -const config = require('../config'); -const utils = require('./utils'); +const config = require('./config'); + +const getUtils = () => require('./utils'); const logDir = config.logDir || `${__dirname}/../logs`; const logFileFormatRegex = /^([0-9\-]+?)__([0-9]+?)__([0-9a-f]+?)\.txt$/; @@ -51,7 +52,7 @@ function getLogFilePath(logFilename) { */ function getLogFileUrl(logFilename) { const info = getLogFileInfo(logFilename); - return utils.getSelfUrl(`logs/${info.token}`); + return getUtils().getSelfUrl(`logs/${info.token}`); } /** diff --git a/src/threads.js b/src/threads.js index 48052a3..c7cb465 100644 --- a/src/threads.js +++ b/src/threads.js @@ -1,8 +1,9 @@ const Eris = require('eris'); const transliterate = require('transliteration'); -const utils = require('./utils'); const jsonDb = require('./jsonDb'); -const config = require('../config'); +const config = require('./config'); + +const getUtils = () => require('./utils'); // If the following messages would be used to start a thread, ignore it instead // This is to prevent accidental threads from e.g. irrelevant replies after the thread was already closed @@ -56,12 +57,11 @@ const accidentalThreadMessages = [ /** * 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, originalMessage = null) { +function getForUser(user, allowCreate = true, originalMessage = null) { return jsonDb.get('threads', []).then(threads => { const thread = threads.find(t => t.userId === user.id); if (thread) return thread; @@ -85,7 +85,7 @@ function getForUser(bot, user, allowCreate = true, originalMessage = null) { } console.log(`[NOTE] Creating new thread channel ${channelName}`); - return utils.getInboxGuild(bot).createChannel(`${channelName}`) + return getUtils().getInboxGuild().createChannel(`${channelName}`) .then(channel => { const thread = { channelId: channel.id, diff --git a/src/utils.js b/src/utils.js index 51f2f2a..d2911dc 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,38 +1,119 @@ const Eris = require('eris'); +const bot = require('./bot'); const moment = require('moment'); const publicIp = require('public-ip'); -const config = require('../config'); -const utils = require('./utils'); +const threads = require('./threads'); +const attachments = require('./attachments'); +const config = require('./config'); + +class BotError extends Error {} + +const userMentionRegex = /^<@\!?([0-9]+?)>$/; let inboxGuild = null; let mainGuild = null; let logChannel = null; -function getInboxGuild(bot) { +function getInboxGuild() { if (! inboxGuild) inboxGuild = bot.guilds.find(g => g.id === config.mailGuildId); + if (! inboxGuild) throw new BotError('The bot is not on the modmail (inbox) server!'); return inboxGuild; } -function getMainGuild(bot) { +function getMainGuild() { if (! mainGuild) mainGuild = bot.guilds.find(g => g.id === config.mainGuildId); + if (! mainGuild) console.warn('[WARN] The bot is not on the main server! If this is intentional, you can ignore this warning.'); return mainGuild; } -function getLogChannel(bot) { - const inboxGuild = getInboxGuild(bot); +/** + * Returns the designated log channel, or the default channel if none is set + * @param bot + * @returns {object} + */ +function getLogChannel() { + const inboxGuild = getInboxGuild(); if (! config.logChannelId) { - return inboxGuild.channels.get(inboxGuild.id); + logChannel = inboxGuild.channels.get(inboxGuild.id); + } else if (! logChannel) { + logChannel = inboxGuild.channels.get(config.logChannelId); } if (! logChannel) { - logChannel = inboxGuild.channels.get(config.logChannelId); + throw new BotError('Log channel not found!'); } return logChannel; } -const userMentionRegex = /^<@\!?([0-9]+?)>$/; +function postError(str) { + getLogChannel().createMessage({ + content: `@here **Error:** ${str.trim()}`, + disableEveryone: false + }); +} + +/** + * Returns whether the given member has permission to use modmail commands + * @param member + * @returns {boolean} + */ +function isStaff(member) { + if (! config.inboxServerPermission) return true; + return member.permission.has(config.inboxServerPermission); +} + +/** + * Returns whether the given message is on the inbox server + * @param msg + * @returns {boolean} + */ +function messageIsOnInboxServer(msg) { + if (! msg.channel.guild) return false; + if (msg.channel.guild.id !== getInboxGuild().id) return false; + return true; +} + +/** + * Returns whether the given message is on the main server + * @param msg + * @returns {boolean} + */ +function messageIsOnMainServer(msg) { + if (! msg.channel.guild) return false; + if (msg.channel.guild.id !== getMainGuild().id) return false; + return true; +} + +/** + * Adds a command that can only be triggered on the inbox server. + * Command handlers added with this function also get the thread the message was posted in as a third argument, if any. + * @param cmd + * @param fn + * @param opts + */ +function addInboxCommand(cmd, fn, opts) { + bot.registerCommand(cmd, async (msg, args) => { + if (! messageIsOnInboxServer(msg)) return; + if (! isStaff(msg.member)) return; + + const thread = await threads.getByChannelId(msg.channel.id); + fn(msg, args, thread); + }, opts); +} + +/** + * @param attachment + * @returns {Promise} + */ +async function formatAttachment(attachment) { + let filesize = attachment.size || 0; + filesize /= 1024; + + const attachmentUrl = await attachments.getUrl(attachment.id, attachment.filename); + return `**Attachment:** ${attachment.filename} (${filesize.toFixed(1)}KB)\n${attachmentUrl}`; +} /** * Returns the user ID of the user mentioned in str, if any @@ -115,9 +196,20 @@ function chunk(items, chunkSize) { } module.exports = { + BotError, + getInboxGuild, getMainGuild, getLogChannel, + postError, + + isStaff, + messageIsOnInboxServer, + messageIsOnMainServer, + addInboxCommand, + + formatAttachment, + getUserMention, getTimestamp, disableLinkPreviews, diff --git a/src/webserver.js b/src/webserver.js index 0d3c3b1..fc1acdd 100644 --- a/src/webserver.js +++ b/src/webserver.js @@ -2,7 +2,7 @@ const http = require('http'); const mime = require('mime'); const url = require('url'); const fs = require('fs'); -const config = require('../config'); +const config = require('./config'); const logs = require('./logs'); const attachments = require('./attachments');