diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b8996c..5444712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v2.0.0 +* Rewrote large parts of the code to be more modular and maintainable. There may be some new bugs because of this - please report them through GitHub issues if you encounter any! +* Threads, logs, and snippets are now stored in an SQLite database. The bot will migrate old data on the first run. +* Fixed system messages like pins in DMs being relayed to the thread +* Fixed channels sometimes being created without a category + ## Sep 22, 2017 * Added `newThreadCategoryId` option. This option can be set to a category ID to place all new threads in that category. diff --git a/db/migrations/20171223203915_create_tables.js b/db/migrations/20171223203915_create_tables.js index ba2b75c..ce81c48 100644 --- a/db/migrations/20171223203915_create_tables.js +++ b/db/migrations/20171223203915_create_tables.js @@ -3,9 +3,9 @@ exports.up = async function(knex, Promise) { table.string('id', 36).notNullable().primary(); table.integer('status').unsigned().notNullable().index(); table.integer('is_legacy').unsigned().notNullable(); - table.bigInteger('user_id').unsigned().notNullable().index(); + table.string('user_id', 20).notNullable().index(); table.string('user_name', 128).notNullable(); - table.bigInteger('channel_id').unsigned().nullable().unique(); + table.string('channel_id', 20).nullable().unique(); table.dateTime('created_at').notNullable().index(); }); @@ -13,18 +13,18 @@ exports.up = async function(knex, Promise) { table.increments('id'); table.string('thread_id', 36).notNullable().index().references('id').inTable('threads').onDelete('CASCADE'); table.integer('message_type').unsigned().notNullable(); - table.bigInteger('user_id').unsigned().nullable(); + table.string('user_id', 20).nullable(); table.string('user_name', 128).notNullable(); table.text('body').notNullable(); table.integer('is_anonymous').unsigned().notNullable(); - table.bigInteger('original_message_id').unsigned().nullable().unique(); + table.string('original_message_id', 20).nullable().unique(); table.dateTime('created_at').notNullable().index(); }); await knex.schema.createTableIfNotExists('blocked_users', table => { - table.bigInteger('user_id').unsigned().primary().notNullable(); + table.string('user_id', 20).primary().notNullable(); table.string('user_name', 128).notNullable(); - table.bigInteger('blocked_by').unsigned().nullable(); + table.string('blocked_by', 20).nullable(); table.dateTime('blocked_at').notNullable(); }); @@ -32,7 +32,7 @@ exports.up = async function(knex, Promise) { table.string('trigger', 32).primary().notNullable(); table.text('body').notNullable(); table.integer('is_anonymous').unsigned().notNullable(); - table.bigInteger('created_by').unsigned().nullable(); + table.string('created_by', 20).nullable(); table.dateTime('created_at').notNullable(); }); }; diff --git a/package-lock.json b/package-lock.json index b37c9c2..787a06f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -413,13 +413,13 @@ "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=" }, "eris": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/eris/-/eris-0.7.2.tgz", - "integrity": "sha512-YcgXHH81tk9/nbnwZZ47cQVtaAySjIJi/JJFt0lbIMTHhHa77zo682nLIJrBbU5P8u9LQUbWoqiFA/NabR3qww==", + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/eris/-/eris-0.8.4.tgz", + "integrity": "sha512-mhQlh5iamo3Ls8xk1xJsu9rHgiW7wkR79e76Nuhrwu1fswnmC7WA/KypGpI51G5h9BFPMxSeFYXy+tyladhJtQ==", "requires": { - "opusscript": "0.0.3", + "opusscript": "0.0.4", "tweetnacl": "1.0.0", - "ws": "3.2.0" + "ws": "3.3.3" } }, "error-ex": { @@ -1668,9 +1668,9 @@ } }, "opusscript": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.3.tgz", - "integrity": "sha1-zkZxf8jW+QHFGR5pSFyImgNqhnQ=", + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/opusscript/-/opusscript-0.0.4.tgz", + "integrity": "sha512-bEPZFE2lhUJYQD5yfTFO4RhbRZ937x6hRwBC1YoGacT35bwDVwKFP1+amU8NYfZL/v4EU7ZTU3INTqzYAnuP7Q==", "optional": true }, "os-homedir": { @@ -3006,9 +3006,9 @@ "dev": true }, "ultron": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.0.tgz", - "integrity": "sha1-sHoualQagV/Go0zNRTO67DB8qGQ=" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz", + "integrity": "sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==" }, "unc-path-regex": { "version": "0.1.2", @@ -3120,13 +3120,13 @@ } }, "ws": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-3.2.0.tgz", - "integrity": "sha512-hTS3mkXm/j85jTQOIcwVz3yK3up9xHgPtgEhDBOH3G18LDOZmSAG1omJeXejLKJakx+okv8vS1sopgs7rw0kVw==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-3.3.3.tgz", + "integrity": "sha512-nnWLa/NwZSt4KQJu51MYlCcSQ5g7INpOrOMt4XV8j4dqTXdmlUmSHQ8/oLC069ckre0fRsgfvsKwbTdtKLCDkA==", "requires": { "async-limiter": "1.0.0", "safe-buffer": "5.1.1", - "ultron": "1.1.0" + "ultron": "1.1.1" }, "dependencies": { "safe-buffer": { diff --git a/package.json b/package.json index dbab6c8..1ace290 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,17 @@ { "name": "modmailbot", - "version": "1.0.0", + "version": "2.0.0", "description": "", "license": "MIT", "main": "src/index.js", "scripts": { - "start": "node --trace-warnings src/index.js", + "start": "node src/index.js", "test": "echo \"Error: no test specified\" && exit 1", "lint": "./node_modules/.bin/eslint ./src" }, "author": "", "dependencies": { - "eris": "^0.7.2", + "eris": "^0.8.4", "humanize-duration": "^3.10.0", "knex": "^0.14.2", "mime": "^1.3.4", diff --git a/src/data/Thread.js b/src/data/Thread.js index 171c024..a4b8cf8 100644 --- a/src/data/Thread.js +++ b/src/data/Thread.js @@ -1,8 +1,13 @@ +const moment = require('moment'); + const bot = require('../bot'); const knex = require('../knex'); const utils = require('../utils'); +const config = require('../config'); const attachments = require('./attachments'); +const ThreadMessage = require('./ThreadMessage'); + const {THREAD_MESSAGE_TYPE, THREAD_STATUS} = require('./constants'); /** @@ -12,17 +17,16 @@ const {THREAD_MESSAGE_TYPE, THREAD_STATUS} = require('./constants'); * @property {String} user_name * @property {String} channel_id * @property {String} created_at - * @property {Boolean} _wasCreated */ class Thread { constructor(props) { - Object.assign(this, {_wasCreated: false}, props); + Object.assign(this, props); } /** - * @param {Eris.Member} moderator + * @param {Eris~Member} moderator * @param {String} text - * @param {Eris.Attachment[]} replyAttachments + * @param {Eris~Attachment[]} replyAttachments * @param {Boolean} isAnonymous * @returns {Promise} */ @@ -81,6 +85,7 @@ class Thread { user_id: moderator.id, user_name: logModUsername, body: logContent, + is_anonymous: (isAnonymous ? 1 : 0), original_message_id: originalMessage.id }); } @@ -92,7 +97,12 @@ class Thread { async receiveUserReply(msg) { const timestamp = utils.getTimestamp(); - let threadContent = `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${msg.content}`; + let content = msg.content; + if (msg.content.trim() === '' && msg.embeds.length) { + content = ''; + } + + let threadContent = `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${content}`; let logContent = msg.content; let finalThreadContent; let attachmentSavePromise; @@ -113,6 +123,7 @@ class Thread { user_id: this.user_id, user_name: `${msg.author.username}#${msg.author.discriminator}`, body: logContent, + is_anonymous: 0, original_message_id: msg.id }); @@ -128,8 +139,7 @@ class Thread { * @returns {Promise} */ async postToThreadChannel(text, file = null) { - const channel = bot.getChannel(this.channel_id); - return channel.createMessage(text, file); + return bot.createMessage(this.channel_id, text, file); } /** @@ -143,10 +153,58 @@ class Thread { user_id: null, user_name: '', body: text, + is_anonymous: 0, original_message_id: msg.id }); } + /** + * @param {String} text + * @returns {Promise} + */ + async postNonLogMessage(text) { + await this.postToThreadChannel(text); + } + + /** + * @param {Eris.Message} msg + * @returns {Promise} + */ + async saveChatMessage(msg) { + return this.addThreadMessageToDB({ + message_type: THREAD_MESSAGE_TYPE.CHAT, + user_id: msg.author.id, + user_name: `${msg.author.username}#${msg.author.discriminator}`, + body: msg.content, + is_anonymous: 0, + original_message_id: msg.id + }); + } + + /** + * @param {Eris.Message} msg + * @returns {Promise} + */ + async updateChatMessage(msg) { + await knex('thread_messages') + .where('thread_id', this.id) + .where('original_message_id', msg.id) + .update({ + content: msg.content + }); + } + + /** + * @param {String} messageId + * @returns {Promise} + */ + async deleteChatMessage(messageId) { + await knex('thread_messages') + .where('thread_id', this.id) + .where('original_message_id', messageId) + .delete(); + } + /** * @param {Object} data * @returns {Promise} @@ -155,14 +213,29 @@ class Thread { await knex('thread_messages').insert({ thread_id: this.id, created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss'), + is_anonymous: 0, ...data }); } + /** + * @returns {Promise} + */ + async getThreadMessages() { + const threadMessages = await knex('thread_messages') + .where('thread_id', this.id) + .orderBy('created_at', 'ASC') + .orderBy('id', 'ASC') + .select(); + + return threadMessages.map(row => new ThreadMessage(row)); + } + /** * @returns {Promise} */ async close() { + console.log(`Closing thread ${this.id}`); await this.postToThreadChannel('Closing thread...'); // Update DB status @@ -173,9 +246,10 @@ class Thread { }); // Delete channel + console.log(`Deleting channel ${this.channel_id}`); const channel = bot.getChannel(this.channel_id); if (channel) { - channel.delete('Thread closed'); + await channel.delete('Thread closed'); } } diff --git a/src/data/ThreadMessage.js b/src/data/ThreadMessage.js new file mode 100644 index 0000000..e647988 --- /dev/null +++ b/src/data/ThreadMessage.js @@ -0,0 +1,18 @@ +/** + * @property {Number} id + * @property {String} thread_id + * @property {Number} message_type + * @property {String} user_id + * @property {String} user_name + * @property {String} body + * @property {Number} is_anonymous + * @property {Number} original_message_id + * @property {String} created_at + */ +class ThreadMessage { + constructor(props) { + Object.assign(this, props); + } +} + +module.exports = ThreadMessage; diff --git a/src/data/attachments.js b/src/data/attachments.js index 0f97f7c..193b108 100644 --- a/src/data/attachments.js +++ b/src/data/attachments.js @@ -5,7 +5,7 @@ const config = require('../config'); const getUtils = () => require('../utils'); -const attachmentDir = config.attachmentDir || `${__dirname}/../attachments`; +const attachmentDir = config.attachmentDir || `${__dirname}/../../attachments`; /** * Returns the filesystem path for the given attachment id @@ -36,7 +36,7 @@ function saveAttachment(attachment, tries = 0) { https.get(attachment.url, (res) => { res.pipe(writeStream); writeStream.on('finish', () => { - writeStream.closeByChannelId() + writeStream.end(); resolve(); }); }).on('error', (err) => { diff --git a/src/data/threads.js b/src/data/threads.js index 135b3f3..3ec839a 100644 --- a/src/data/threads.js +++ b/src/data/threads.js @@ -12,6 +12,14 @@ const utils = require('../utils'); const Thread = require('./Thread'); const {THREAD_STATUS} = require('./constants'); +async function findById(id) { + const thread = await knex('threads') + .where('id', id) + .first(); + + return (thread ? new Thread(thread) : null); +} + /** * @param {String} userId * @returns {Promise} @@ -20,7 +28,7 @@ async function findOpenThreadByUserId(userId) { const thread = await knex('threads') .where('user_id', userId) .where('status', THREAD_STATUS.OPEN) - .select(); + .first(); return (thread ? new Thread(thread) : null); } @@ -50,11 +58,7 @@ async function createNewThreadForUser(user) { // Attempt to create the inbox channel for this thread let createdChannel; try { - createdChannel = await utils.getInboxGuild().createChannel(channelName); - if (config.newThreadCategoryId) { - // If a category id for new threads is specified, move the newly created channel there - bot.editChannel(createdChannel.id, {parentID: config.newThreadCategoryId}); - } + createdChannel = await utils.getInboxGuild().createChannel(channelName, null, 'New ModMail thread', config.newThreadCategoryId); } catch (err) { console.error(`Error creating modmail channel for ${user.username}#${user.discriminator}!`); throw err; @@ -71,6 +75,10 @@ async function createNewThreadForUser(user) { const newThread = await findById(newThreadId); + // Post the log link to the beginning (but don't save it in thread messages) + const logUrl = await newThread.getLogUrl(); + await newThread.postNonLogMessage(`Log URL: <${logUrl}>`); + // Post some info to the beginning of the new thread const mainGuild = utils.getMainGuild(); const member = (mainGuild ? mainGuild.members.get(user.id) : null); @@ -134,6 +142,19 @@ async function findByChannelId(channelId) { return (thread ? new Thread(thread) : null); } +/** + * @param {String} channelId + * @returns {Promise} + */ +async function findOpenThreadByChannelId(channelId) { + const thread = await knex('threads') + .where('channel_id', channelId) + .where('status', THREAD_STATUS.OPEN) + .first(); + + return (thread ? new Thread(thread) : null); +} + /** * @param {String} userId * @returns {Promise} @@ -160,9 +181,20 @@ async function getClosedThreadCountByUserId(userId) { return parseInt(row.thread_count, 10); } +async function findOrCreateThreadForUser(user) { + const existingThread = await findOpenThreadByUserId(user.id); + if (existingThread) return existingThread; + + return createNewThreadForUser(user); +} + module.exports = { + findById, findOpenThreadByUserId, findByChannelId, + findOpenThreadByChannelId, createNewThreadForUser, getClosedThreadsByUserId, + findOrCreateThreadForUser, + createThreadInDB }; diff --git a/src/index.js b/src/index.js index 8c30a8f..5c09c1d 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,21 @@ +// Verify NodeJS version +const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); +if (nodeMajorVersion < 8) { + console.error('Unsupported NodeJS version! Please install NodeJS 8 or newer.'); + process.exit(1); +} + +// Verify node modules have been installed +const fs = require('fs'); const path = require('path'); + +try { + fs.accessSync(path.join(__dirname, '..', 'node_modules')); +} catch (e) { + console.error('Please run "npm install" before trying to start the bot.'); + process.exit(1); +} + const config = require('./config'); const utils = require('./utils'); const main = require('./main'); diff --git a/src/legacy/legacyMigrator.js b/src/legacy/legacyMigrator.js index 44d3213..b4e8f7a 100644 --- a/src/legacy/legacyMigrator.js +++ b/src/legacy/legacyMigrator.js @@ -9,6 +9,8 @@ const config = require('../config'); const jsonDb = require('./jsonDb'); const threads = require('../data/threads'); +const {THREAD_STATUS, THREAD_MESSAGE_TYPE} = require('../data/constants'); + const readDir = promisify(fs.readdir); const readFile = promisify(fs.readFile); const access = promisify(fs.access); @@ -75,7 +77,7 @@ async function migrateOpenThreads() { if (existingOpenThread) return; const newThread = { - status: threads.THREAD_STATUS.OPEN, + status: THREAD_STATUS.OPEN, user_id: oldThread.userId, user_name: oldThread.username, channel_id: oldThread.channelId, @@ -103,7 +105,7 @@ async function migrateLogs() { const newThread = { id: threadId, - status: threads.THREAD_STATUS.CLOSED, + status: THREAD_STATUS.CLOSED, user_id: userId, user_name: '', channel_id: null, @@ -122,10 +124,11 @@ async function migrateLogs() { await trx('thread_messages').insert({ thread_id: newThread.id, - message_type: threads.THREAD_MESSAGE_TYPE.LEGACY, + message_type: THREAD_MESSAGE_TYPE.LEGACY, user_id: userId, user_name: '', body: contents, + is_anonymous: 0, created_at: date }); }); diff --git a/src/main.js b/src/main.js index 7a80148..3dcafe4 100644 --- a/src/main.js +++ b/src/main.js @@ -5,48 +5,28 @@ const config = require('./config'); const bot = require('./bot'); const Queue = require('./queue'); const utils = require('./utils'); +const threadUtils = require('./threadUtils'); const blocked = require('./data/blocked'); const threads = require('./data/threads'); -const snippets = require('./data/snippets'); -const webserver = require('./webserver'); -const greeting = require('./greeting'); -const Thread = require('./data/Thread'); + +const snippets = require('./plugins/snippets'); +const webserver = require('./plugins/webserver'); +const greeting = require('./plugins/greeting'); const messageQueue = new Queue(); -/** - * @callback CommandHandlerCB - * @interface - * @param {Eris~Message} msg - * @param {Array} args - * @param {Thread} thread - * @return void - */ - -/** - * 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 {String} cmd - * @param {CommandHandlerCB} commandHandler - * @param {Eris~CommandOptions} opts - */ -function addInboxServerCommand(cmd, commandHandler, opts) { - bot.registerCommand(cmd, async (msg, args) => { - if (! messageIsOnInboxServer(msg)) return; - if (! isStaff(msg.member)) return; - - const thread = await threads.findByChannelId(msg.channel.id); - commandHandler(msg, args, thread); - }, opts); -} +const addInboxServerCommand = (...args) => threadUtils.addInboxServerCommand(bot, ...args); // Once the bot has connected, set the status/"playing" message bot.on('ready', () => { bot.editStatus(null, {name: config.status}); - console.log('Bot started, listening to DMs'); }); -// Handle moderator messages in thread channels +/** + * When a moderator posts in a modmail thread... + * 1) If alwaysReply is enabled, reply to the user + * 2) If alwaysReply is disabled, save that message as a chat message in the thread + */ bot.on('messageCreate', async msg => { if (! utils.messageIsOnInboxServer(msg)) return; if (! utils.isStaff(msg)) return; @@ -62,17 +42,81 @@ bot.on('messageCreate', async msg => { msg.delete(); } else { // Otherwise just save the messages as "chat" in the logs - thread.addThreadMessageToDB({ - message_type: threads.THREAD_MESSAGE_TYPE.CHAT, - user_id: msg.author.id, - user_name: `${msg.author.username}#${msg.author.discriminator}`, - body: msg.content, - original_message_id: msg.id - }); + thread.saveChatMessage(msg); } }); -// If the bot is mentioned on the main server, post a log message about it +/** + * When we get a private message... + * 1) Find the open modmail thread for this user, or create a new one + * 2) Post the message as a user reply in the thread + */ +bot.on('messageCreate', async msg => { + if (! (msg.channel instanceof Eris.PrivateChannel)) return; + if (msg.author.bot) return; + if (msg.type !== 0) return; // Ignore pins etc. + + if (await blocked.isBlocked(msg.author.id)) return; + + // Private message handling is queued so e.g. multiple message in quick succession don't result in multiple channels being created + messageQueue.add(async () => { + const thread = await threads.findOrCreateThreadForUser(msg.author); + await thread.receiveUserReply(msg); + }); +}); + +/** + * When a message is edited... + * 1) If that message was in DMs, and we have a thread open with that user, post the edit as a system message in the thread + * 2) If that message was moderator chatter in the thread, update the corresponding chat message in the DB + */ +bot.on('messageUpdate', async (msg, oldMessage) => { + if (msg.author.bot) return; + if (await blocked.isBlocked(msg.author.id)) return; + + let oldContent = oldMessage.content; + const newContent = msg.content; + + // 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; + + // 1) Edit in DMs + if (msg.channel instanceof Eris.PrivateChannel) { + const thread = await threads.findOpenThreadByUserId(msg.author.id); + const editMessage = utils.disableLinkPreviews(`**The user edited their message:**\n\`B:\` ${oldContent}\n\`A:\` ${newContent}`); + + thread.postSystemMessage(editMessage); + } + + // 2) Edit in the thread + else if (utils.messageIsOnInboxServer(msg) && utils.isStaff(msg.member)) { + const thread = await threads.findOpenThreadByChannelId(msg.channel.id); + if (! thread) return; + + thread.updateChatMessage(msg); + } +}); + +/** + * When a staff message is deleted in a modmail thread, delete it from the database as well + */ +bot.on('messageDelete', async msg => { + if (msg.author.bot) return; + if (! utils.messageIsOnInboxServer(msg)) return; + if (! utils.isStaff(msg.member)) return; + + const thread = await threads.findOpenThreadByChannelId(msg.channel.id); + if (! thread) return; + + thread.deleteChatMessage(msg.id); +}); + +/** + * When the bot is mentioned on the main server, ping staff in the log channel about it + */ bot.on('messageCreate', async msg => { if (! utils.messageIsOnMainServer(msg)) return; if (! msg.mentions.some(user => user.id === bot.user.id)) return; @@ -89,62 +133,25 @@ bot.on('messageCreate', async msg => { }); }); -// When we get a private message, forward the contents to the corresponding modmail thread -bot.on('messageCreate', async msg => { - if (! (msg.channel instanceof Eris.PrivateChannel)) return; - if (msg.author.id === bot.user.id) return; - - if (await blocked.isBlocked(msg.author.id)) return; - - // 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 = await threads.findOpenThreadByUserId(msg.author.id); - if (! thread) { - thread = await threads.createNewThreadForUser(msg.author, msg); - } - - thread.receiveUserReply(msg); - }); -}); - -// Edits in DMs -bot.on('messageUpdate', async (msg, oldMessage) => { - if (! (msg.channel instanceof Eris.PrivateChannel)) return; - if (msg.author.id === bot.user.id) return; - - if (await blocked.isBlocked(msg.author.id)) return; - - let oldContent = oldMessage.content; - const newContent = msg.content; - - // 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; - - const thread = await threads.createNewThreadForUser(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); -}); - // 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 -addInboxServerCommand('reply', (msg, args, thread) => { +addInboxServerCommand('reply', async (msg, args, thread) => { if (! thread) return; + const text = args.join(' ').trim(); - thread.replyToUser(msg.member, text, msg.attachments, false); + await thread.replyToUser(msg.member, text, msg.attachments, false); + msg.delete(); }); bot.registerCommandAlias('r', 'reply'); // Anonymous replies only show the role, not the username -addInboxServerCommand('anonreply', (msg, args, thread) => { +addInboxServerCommand('anonreply', async (msg, args, thread) => { if (! thread) return; + const text = args.join(' ').trim(); - thread.replyToUser(msg.member, text, msg.attachments, true); + await thread.replyToUser(msg.member, text, msg.attachments, true); + msg.delete(); }); bot.registerCommandAlias('ar', 'anonreply'); @@ -168,7 +175,7 @@ addInboxServerCommand('block', (msg, args, thread) => { block(userId); } else if (thread) { // Calling !block without args in a modmail thread blocks the user of that thread - block(thread.userId); + block(thread.user_id); } }); @@ -185,7 +192,7 @@ addInboxServerCommand('unblock', (msg, args, thread) => { unblock(userId); } else if (thread) { // Calling !unblock without args in a modmail thread unblocks the user of that thread - unblock(thread.userId); + unblock(thread.user_id); } }); @@ -217,105 +224,21 @@ addInboxServerCommand('logs', (msg, args, thread) => { getLogs(userId); } else if (thread) { // Calling !logs without args in a modmail thread returns the logs of the user of that thread - getLogs(thread.userId); + getLogs(thread.user_id); } }); -// Snippets -bot.on('messageCreate', async msg => { - if (! utils.messageIsOnInboxServer(msg)) return; - if (! utils.isStaff(msg.member)) return; - - if (msg.author.bot) return; - if (! msg.content) return; - if (! msg.content.startsWith(config.snippetPrefix)) return; - - const shortcut = msg.content.replace(config.snippetPrefix, '').toLowerCase(); - const snippet = await snippets.get(shortcut); - if (! snippet) return; - - reply(msg, snippet.text, snippet.isAnonymous); -}); - -// Show or add a snippet -addInboxServerCommand('snippet', async (msg, args) => { - const shortcut = args[0]; - if (! shortcut) return - - const text = args.slice(1).join(' ').trim(); - 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(`\`${config.snippetPrefix}${shortcut}\` replies ${snippet.isAnonymous ? 'anonymously ' : ''}with:\n${snippet.text}`); - } - } else { - if (text) { - // If the snippet doesn't exist and the user wants to create it, create it - await snippets.add(shortcut, text, false); - msg.channel.createMessage(`Snippet "${shortcut}" created!`); - } 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\``); - } - } -}); - -bot.registerCommandAlias('s', 'snippet'); - -addInboxServerCommand('delete_snippet', async (msg, args) => { - const shortcut = args[0]; - if (! shortcut) return; - - const snippet = await snippets.get(shortcut); - if (! snippet) { - msg.channel.createMessage(`Snippet "${shortcut}" doesn't exist!`); - return; - } - - await snippets.del(shortcut); - msg.channel.createMessage(`Snippet "${shortcut}" deleted!`); -}); - -bot.registerCommandAlias('ds', 'delete_snippet'); - -addInboxServerCommand('edit_snippet', async (msg, args) => { - const shortcut = args[0]; - if (! shortcut) return; - - const text = args.slice(1).join(' ').trim(); - if (! text) return; - - const snippet = await snippets.get(shortcut); - if (! snippet) { - msg.channel.createMessage(`Snippet "${shortcut}" doesn't exist!`); - return; - } - - await snippets.del(shortcut); - await snippets.add(shortcut, text, snippet.isAnonymous); - - msg.channel.createMessage(`Snippet "${shortcut}" edited!`); -}); - -bot.registerCommandAlias('es', 'edit_snippet'); - -addInboxServerCommand('snippets', async msg => { - const allSnippets = await snippets.all(); - const shortcuts = Object.keys(allSnippets); - shortcuts.sort(); - - msg.channel.createMessage(`Available snippets (prefix ${config.snippetPrefix}):\n${shortcuts.join(', ')}`); -}); - module.exports = { - start() { - bot.connect(); - // webserver.run(); - greeting.init(bot); + async start() { + // Load plugins + console.log('Loading plugins...'); + await snippets(bot); + await greeting(bot); + await webserver(bot); + + console.log('Connecting to Discord...'); + await bot.connect(); + + console.log('Done! Now listening to DMs.'); } }; diff --git a/src/greeting.js b/src/plugins/greeting.js similarity index 78% rename from src/greeting.js rename to src/plugins/greeting.js index f1f50ad..879310e 100644 --- a/src/greeting.js +++ b/src/plugins/greeting.js @@ -1,12 +1,12 @@ const path = require('path'); const fs = require('fs'); -const config = require('./config'); +const config = require('../config'); -const greetingGuildId = config.mainGuildId || config.greetingGuildId; - -function init(bot) { +module.exports = bot => { if (! config.enableGreeting) return; + const greetingGuildId = config.mainGuildId || config.greetingGuildId; + bot.on('guildMemberAdd', (guild, member) => { if (guild.id !== greetingGuildId) return; @@ -24,11 +24,7 @@ function init(bot) { sendGreeting(file); }); } else { - sendGreeting(); + sendGreeting(); } }); -} - -module.exports = { - init, }; diff --git a/src/plugins/snippets.js b/src/plugins/snippets.js new file mode 100644 index 0000000..3f20ac9 --- /dev/null +++ b/src/plugins/snippets.js @@ -0,0 +1,106 @@ +const threads = require('../data/threads'); +const snippets = require('../data/snippets'); +const config = require('../config'); +const utils = require('../utils'); +const threadUtils = require('../threadUtils'); + +module.exports = bot => { + const addInboxServerCommand = (...args) => threadUtils.addInboxServerCommand(bot, ...args); + + /** + * When a staff member uses a snippet (snippet prefix + trigger word), find the snippet and post it as a reply in the thread + */ + bot.on('messageCreate', async msg => { + if (! utils.messageIsOnInboxServer(msg)) return; + if (! utils.isStaff(msg.member)) return; + + if (msg.author.bot) return; + if (! msg.content) return; + if (! msg.content.startsWith(config.snippetPrefix)) return; + + const thread = await threads.findByChannelId(msg.channel.id); + if (! thread) return; + + const trigger = msg.content.replace(config.snippetPrefix, '').toLowerCase(); + const snippet = await snippets.get(trigger); + if (! snippet) return; + + await thread.replyToUser(msg.member, snippet.body, [], !! snippet.is_anonymous); + msg.delete(); + }); + + // Show or add a snippet + addInboxServerCommand('snippet', async (msg, args) => { + const trigger = args[0]; + if (! trigger) return + + const text = args.slice(1).join(' ').trim(); + const snippet = await snippets.get(trigger); + + 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 "${trigger}" already exists! You can edit or delete it with ${config.prefix}edit_snippet and ${config.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(`\`${config.snippetPrefix}${trigger}\` replies ${snippet.is_anonymous ? 'anonymously ' : ''}with:\n${snippet.body}`); + } + } else { + if (text) { + // If the snippet doesn't exist and the user wants to create it, create it + await snippets.add(trigger, text, false); + msg.channel.createMessage(`Snippet "${trigger}" created!`); + } 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 "${trigger}" doesn't exist! You can create it with \`${config.prefix}snippet ${trigger} text\``); + } + } + }); + + bot.registerCommandAlias('s', 'snippet'); + + addInboxServerCommand('delete_snippet', async (msg, args) => { + const trigger = args[0]; + if (! trigger) return; + + const snippet = await snippets.get(trigger); + if (! snippet) { + msg.channel.createMessage(`Snippet "${trigger}" doesn't exist!`); + return; + } + + await snippets.del(trigger); + msg.channel.createMessage(`Snippet "${trigger}" deleted!`); + }); + + bot.registerCommandAlias('ds', 'delete_snippet'); + + addInboxServerCommand('edit_snippet', async (msg, args) => { + const trigger = args[0]; + if (! trigger) return; + + const text = args.slice(1).join(' ').trim(); + if (! text) return; + + const snippet = await snippets.get(trigger); + if (! snippet) { + msg.channel.createMessage(`Snippet "${trigger}" doesn't exist!`); + return; + } + + await snippets.del(trigger); + await snippets.add(trigger, text, snippet.isAnonymous); + + msg.channel.createMessage(`Snippet "${trigger}" edited!`); + }); + + bot.registerCommandAlias('es', 'edit_snippet'); + + addInboxServerCommand('snippets', async msg => { + const allSnippets = await snippets.all(); + const triggers = allSnippets.map(s => s.trigger); + triggers.sort(); + + msg.channel.createMessage(`Available snippets (prefix ${config.snippetPrefix}):\n${triggers.join(', ')}`); + }); +}; diff --git a/src/plugins/webserver.js b/src/plugins/webserver.js new file mode 100644 index 0000000..70fc98e --- /dev/null +++ b/src/plugins/webserver.js @@ -0,0 +1,88 @@ +const http = require('http'); +const mime = require('mime'); +const url = require('url'); +const fs = require('fs'); +const moment = require('moment'); +const config = require('../config'); +const threads = require('../data/threads'); +const attachments = require('../data/attachments'); + +const {THREAD_MESSAGE_TYPE} = require('../data/constants'); + +function notfound(res) { + res.statusCode = 404; + res.end('Page Not Found'); +} + +async function serveLogs(res, pathParts) { + const threadId = pathParts[pathParts.length - 1]; + if (threadId.match(/^[0-9a-f\-]+$/) === null) return notfound(res); + + const thread = await threads.findById(threadId); + if (! thread) return notfound(res); + + const threadMessages = await thread.getThreadMessages(); + const lines = threadMessages.map(message => { + // Legacy messages are the entire log in one message, so just serve them as they are + if (message.message_type === THREAD_MESSAGE_TYPE.LEGACY) { + return message.body; + } + + let line = `[${moment(message.created_at).format('YYYY-MM-DD HH:mm:ss')}] `; + + if (message.message_type === THREAD_MESSAGE_TYPE.SYSTEM) { + // System messages don't need the username + line += message.body; + } else if (message.message_type === THREAD_MESSAGE_TYPE.FROM_USER) { + line += `[FROM USER] ${message.user_name}: ${message.body}`; + } else if (message.message_type === THREAD_MESSAGE_TYPE.TO_USER) { + line += `[TO USER] ${message.user_name}: ${message.body}`; + } else { + line += `${message.user_name}: ${message.body}`; + } + + return line; + }); + + res.setHeader('Content-Type', 'text/plain; charset=UTF-8'); + res.end(lines.join('\n')); +} + +function serveAttachments(res, pathParts) { + const desiredFilename = pathParts[pathParts.length - 1]; + const id = pathParts[pathParts.length - 2]; + + if (id.match(/^[0-9]+$/) === null) return notfound(res); + if (desiredFilename.match(/^[0-9a-z\._-]+$/i) === null) return notfound(res); + + const attachmentPath = attachments.getPath(id); + fs.access(attachmentPath, (err) => { + if (err) return notfound(res); + + 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); + }) +} + +module.exports = () => { + 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); + } else if (parsedUrl.path.startsWith('/attachments/')) { + serveAttachments(res, pathParts); + } else { + notfound(res); + } + }); + + server.listen(config.port); +}; diff --git a/src/threadUtils.js b/src/threadUtils.js new file mode 100644 index 0000000..632de08 --- /dev/null +++ b/src/threadUtils.js @@ -0,0 +1,24 @@ +const threads = require('./data/threads'); +const utils = require("./utils"); + +/** + * 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 {Eris~CommandClient} bot + * @param {String} cmd + * @param {Function} commandHandler + * @param {Eris~CommandOptions} opts + */ +function addInboxServerCommand(bot, cmd, commandHandler, opts) { + bot.registerCommand(cmd, async (msg, args) => { + if (! utils.messageIsOnInboxServer(msg)) return; + if (! utils.isStaff(msg.member)) return; + + const thread = await threads.findByChannelId(msg.channel.id); + commandHandler(msg, args, thread); + }, opts); +} + +module.exports = { + addInboxServerCommand +}; diff --git a/src/utils.js b/src/utils.js index c2c27ae..ea3a08c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -13,12 +13,18 @@ let inboxGuild = null; let mainGuild = null; let logChannel = null; +/** + * @returns {Eris~Guild} + */ 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; } +/** + * @returns {Eris~Guild} + */ 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.'); @@ -28,7 +34,7 @@ function getMainGuild() { /** * Returns the designated log channel, or the default channel if none is set * @param bot - * @returns {object} + * @returns {Eris~TextChannel} */ function getLogChannel() { const inboxGuild = getInboxGuild(); @@ -151,8 +157,8 @@ async function getSelfUrl(path = '') { /** * Returns the highest hoisted role of the given member - * @param {Eris.Member} member - * @returns {Eris.Role} + * @param {Eris~Member} member + * @returns {Eris~Role} */ function getMainRole(member) { const roles = member.roles.map(id => member.guild.roles.get(id)); diff --git a/src/webserver.js b/src/webserver.js deleted file mode 100644 index a6d4108..0000000 --- a/src/webserver.js +++ /dev/null @@ -1,70 +0,0 @@ -const http = require('http'); -const mime = require('mime'); -const url = require('url'); -const fs = require('fs'); -const config = require('./config'); -const attachments = require('./data/attachments'); - -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(); - - logs.findLogFile(token).then(logFilename => { - if (logFilename === null) return res.end(); - - fs.readFile(logs.getLogFilePath(logFilename), {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 = attachments.getPath(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(port); -} - -module.exports = { - run, -};