diff --git a/.eslintrc b/.eslintrc index a419dbf..47af5ef 100644 --- a/.eslintrc +++ b/.eslintrc @@ -3,7 +3,10 @@ "parserOptions": { "ecmaVersion": 8, - "sourceType": "module" + "sourceType": "module", + "ecmaFeatures": { + "experimentalObjectRestSpread": true + } }, "env": { diff --git a/db/migrations/20171223203915_create_tables.js b/db/migrations/20171223203915_create_tables.js index d12fdda..ba2b75c 100644 --- a/db/migrations/20171223203915_create_tables.js +++ b/db/migrations/20171223203915_create_tables.js @@ -13,10 +13,11 @@ 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().notNullable(); + table.bigInteger('user_id').unsigned().nullable(); table.string('user_name', 128).notNullable(); table.text('body').notNullable(); - table.bigInteger('original_message_id').unsigned().nullable(); + table.integer('is_anonymous').unsigned().notNullable(); + table.bigInteger('original_message_id').unsigned().nullable().unique(); table.dateTime('created_at').notNullable().index(); }); diff --git a/src/config.js b/src/config.js index 1dac399..d2fba6b 100644 --- a/src/config.js +++ b/src/config.js @@ -41,6 +41,8 @@ const defaultConfig = { "logDir": path.join(__dirname, '..', 'logs'), }; +const required = ['token', 'mailGuildId', 'mainGuildId', 'logChannelId']; + const finalConfig = Object.assign({}, defaultConfig); for (const [prop, value] of Object.entries(userConfig)) { @@ -67,7 +69,6 @@ Object.assign(finalConfig['knex'], { } }); -const required = ['token', 'mailGuildId', 'mainGuildId', 'logChannelId']; for (const opt of required) { if (! finalConfig[opt]) { console.error(`Missing required config.json value: ${opt}`); diff --git a/src/data/Snippet.js b/src/data/Snippet.js new file mode 100644 index 0000000..28a6987 --- /dev/null +++ b/src/data/Snippet.js @@ -0,0 +1,14 @@ +/** + * @property {String} trigger + * @property {String} body + * @property {Number} is_anonymous + * @property {String} created_by + * @property {String} created_at + */ +class Snippet { + constructor(props) { + Object.assign(this, props); + } +} + +module.exports = Snippet; diff --git a/src/data/Thread.js b/src/data/Thread.js new file mode 100644 index 0000000..171c024 --- /dev/null +++ b/src/data/Thread.js @@ -0,0 +1,190 @@ +const bot = require('../bot'); +const knex = require('../knex'); +const utils = require('../utils'); +const attachments = require('./attachments'); + +const {THREAD_MESSAGE_TYPE, THREAD_STATUS} = require('./constants'); + +/** + * @property {String} id + * @property {Number} status + * @property {String} user_id + * @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); + } + + /** + * @param {Eris.Member} moderator + * @param {String} text + * @param {Eris.Attachment[]} replyAttachments + * @param {Boolean} isAnonymous + * @returns {Promise} + */ + async replyToUser(moderator, text, replyAttachments = [], isAnonymous = false) { + // Try to open a DM channel with the user + const dmChannel = await bot.getDMChannel(this.user_id); + if (! dmChannel) { + const channel = bot.getChannel(this.channel_id); + if (channel) { + channel.createMessage('Could not send reply: couldn\'t open DM channel with user'); + } + return; + } + + // Username to reply with + let modUsername, logModUsername; + const mainRole = utils.getMainRole(moderator); + + if (isAnonymous) { + modUsername = (mainRole ? mainRole.name : 'Moderator'); + logModUsername = `(Anonymous) (${moderator.user.username}) ${mainRole ? mainRole.name : 'Moderator'}`; + } else { + const name = (config.useNicknames ? moderator.nick || moderator.user.username : moderator.user.username); + modUsername = (mainRole ? `(${mainRole.name}) ${name}` : name); + logModUsername = modUsername; + } + + // Build the reply message + let dmContent = `**${modUsername}:** ${text}`; + let threadContent = `**${logModUsername}:** ${text}`; + let logContent = text; + + let attachmentFile = null; + let attachmentUrl = null; + + // Prepare attachments, if any + if (replyAttachments.length > 0) { + fs.readFile(attachments.getPath(replyAttachments[0].id), async (err, data) => { + attachmentFile = {file: data, name: replyAttachments[0].filename}; + attachmentUrl = await attachments.getUrl(replyAttachments[0].id, replyAttachments[0].filename); + + threadContent += `\n\n**Attachment:** ${attachmentUrl}`; + logContent += `\n\n**Attachment:** ${attachmentUrl}`; + }); + } + + // Send the reply DM + dmChannel.createMessage(dmContent, attachmentFile); + + // Send the reply to the modmail thread + const originalMessage = await this.postToThreadChannel(threadContent); + + // Add the message to the database + await this.addThreadMessageToDB({ + message_type: THREAD_MESSAGE_TYPE.TO_USER, + user_id: moderator.id, + user_name: logModUsername, + body: logContent, + original_message_id: originalMessage.id + }); + } + + /** + * @param {Eris.Message} msg + * @returns {Promise} + */ + async receiveUserReply(msg) { + const timestamp = utils.getTimestamp(); + + let threadContent = `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${msg.content}`; + let logContent = msg.content; + let finalThreadContent; + let attachmentSavePromise; + + if (msg.attachments.length) { + attachmentSavePromise = attachments.saveAttachmentsInMessage(msg); + const formattedAttachments = await Promise.all(msg.attachments.map(utils.formatAttachment)); + const attachmentMsg = `\n\n` + formattedAttachments.reduce((str, formatted) => str + `\n\n${formatted}`); + + finalThreadContent = threadContent + attachmentMsg; + threadContent += '\n\n*Attachments pending...*'; + logContent += attachmentMsg; + } + + const createdMessage = await this.postToThreadChannel(threadContent); + await this.addThreadMessageToDB({ + message_type: THREAD_MESSAGE_TYPE.FROM_USER, + user_id: this.user_id, + user_name: `${msg.author.username}#${msg.author.discriminator}`, + body: logContent, + original_message_id: msg.id + }); + + if (msg.attachments.length) { + await attachmentSavePromise; + await createdMessage.edit(finalThreadContent); + } + } + + /** + * @param {String} text + * @param {Eris.MessageFile} file + * @returns {Promise} + */ + async postToThreadChannel(text, file = null) { + const channel = bot.getChannel(this.channel_id); + return channel.createMessage(text, file); + } + + /** + * @param {String} text + * @returns {Promise} + */ + async postSystemMessage(text) { + const msg = await this.postToThreadChannel(text); + await this.addThreadMessageToDB({ + message_type: THREAD_MESSAGE_TYPE.SYSTEM, + user_id: null, + user_name: '', + body: text, + original_message_id: msg.id + }); + } + + /** + * @param {Object} data + * @returns {Promise} + */ + async addThreadMessageToDB(data) { + await knex('thread_messages').insert({ + thread_id: this.id, + created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss'), + ...data + }); + } + + /** + * @returns {Promise} + */ + async close() { + await this.postToThreadChannel('Closing thread...'); + + // Update DB status + await knex('threads') + .where('id', this.id) + .update({ + status: THREAD_STATUS.CLOSED + }); + + // Delete channel + const channel = bot.getChannel(this.channel_id); + if (channel) { + channel.delete('Thread closed'); + } + } + + /** + * @returns {Promise} + */ + getLogUrl() { + return utils.getSelfUrl(`logs/${this.id}`); + } +} + +module.exports = Thread; diff --git a/src/data/blocked.js b/src/data/blocked.js index 8efeffb..50b494f 100644 --- a/src/data/blocked.js +++ b/src/data/blocked.js @@ -11,15 +11,17 @@ async function isBlocked(userId) { .where('user_id', userId) .first(); - return !!row; + return !! row; } /** * Blocks the given userId * @param {String} userId + * @param {String} userName + * @param {String} blockedBy * @returns {Promise} */ -async function block(userId, userName = '', blockedBy = 0) { +async function block(userId, userName = '', blockedBy = null) { if (await isBlocked(userId)) return; return knex('blocked_users') diff --git a/src/data/constants.js b/src/data/constants.js new file mode 100644 index 0000000..60e3277 --- /dev/null +++ b/src/data/constants.js @@ -0,0 +1,52 @@ +module.exports = { + THREAD_STATUS: { + OPEN: 1, + CLOSED: 2 + }, + + THREAD_MESSAGE_TYPE: { + SYSTEM: 1, + CHAT: 2, + FROM_USER: 3, + TO_USER: 4, + LEGACY: 5 + }, + + ACCIDENTAL_THREAD_MESSAGES: [ + 'ok', + 'okay', + 'thanks', + 'ty', + 'k', + 'thank you', + 'thanx', + 'thnx', + 'thx', + 'tnx', + 'ok thank you', + 'ok thanks', + 'ok ty', + 'ok thanx', + 'ok thnx', + 'ok thx', + 'ok no problem', + 'ok np', + 'okay thank you', + 'okay thanks', + 'okay ty', + 'okay thanx', + 'okay thnx', + 'okay thx', + 'okay no problem', + 'okay np', + 'okey thank you', + 'okey thanks', + 'okey ty', + 'okey thanx', + 'okey thnx', + 'okey thx', + 'okey no problem', + 'okey np', + 'cheers' + ], +}; diff --git a/src/data/snippets.js b/src/data/snippets.js index 85bccef..b0d98aa 100644 --- a/src/data/snippets.js +++ b/src/data/snippets.js @@ -1,18 +1,6 @@ const moment = require('moment'); const knex = require('../knex'); - -/** - * @property {String} trigger - * @property {String} body - * @property {Number} is_anonymous - * @property {String} created_by - * @property {String} created_at - */ -class Snippet { - constructor(props) { - Object.assign(this, props); - } -} +const Snippet = require('./Snippet'); /** * @param {String} trigger @@ -46,7 +34,7 @@ async function addSnippet(trigger, body, isAnonymous = false, createdBy = 0) { /** * @param {String} trigger - * @returns {Promise} + * @returns {Promise} */ async function deleteSnippet(trigger) { return knex('snippets') @@ -54,6 +42,9 @@ async function deleteSnippet(trigger) { .delete(); } +/** + * @returns {Promise} + */ async function getAllSnippets() { const snippets = await knex('snippets') .select(); diff --git a/src/data/threads.js b/src/data/threads.js index 589b3cb..135b3f3 100644 --- a/src/data/threads.js +++ b/src/data/threads.js @@ -2,115 +2,39 @@ const Eris = require('eris'); const transliterate = require('transliteration'); const moment = require('moment'); const uuid = require('uuid'); +const humanizeDuration = require('humanize-duration'); const bot = require('../bot'); const knex = require('../knex'); const config = require('../config'); +const utils = require('../utils'); -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 -// or replies to the greeting message -const accidentalThreadMessages = [ - 'ok', - 'okay', - 'thanks', - 'ty', - 'k', - 'thank you', - 'thanx', - 'thnx', - 'thx', - 'tnx', - 'ok thank you', - 'ok thanks', - 'ok ty', - 'ok thanx', - 'ok thnx', - 'ok thx', - 'ok no problem', - 'ok np', - 'okay thank you', - 'okay thanks', - 'okay ty', - 'okay thanx', - 'okay thnx', - 'okay thx', - 'okay no problem', - 'okay np', - 'okey thank you', - 'okey thanks', - 'okey ty', - 'okey thanx', - 'okey thnx', - 'okey thx', - 'okey no problem', - 'okey np', - 'cheers' -]; - -const THREAD_STATUS = { - OPEN: 1, - CLOSED: 2 -}; - -const THREAD_MESSAGE_TYPE = { - SYSTEM: 1, - CHAT: 2, - FROM_USER: 3, - TO_USER: 4, - LEGACY: 5 -}; +const Thread = require('./Thread'); +const {THREAD_STATUS} = require('./constants'); /** - * @property {Number} id - * @property {Number} status - * @property {String} user_id - * @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); - } -} - -/** - * 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.User} user - * @param {Boolean} allowCreate + * @param {String} userId * @returns {Promise} */ -async function getOpenThreadForUser(user, allowCreate = true, originalMessage = null) { - // Attempt to find an open thread for this user +async function findOpenThreadByUserId(userId) { const thread = await knex('threads') - .where('user_id', user.id) + .where('user_id', userId) .where('status', THREAD_STATUS.OPEN) .select(); - if (thread) { - return new Thread(thread); - } + return (thread ? new Thread(thread) : null); +} - // If no open thread was found, and we're not allowed to create one, just return null - if (! allowCreate) { - return null; - } - - // No open thread was found, and we *are* allowed to create a new one, so let's do that - - // If the message's content matches any of the values in accidentalThreadMessages, - // and config.ignoreAccidentalThreads is enabled, ignore this thread - if (config.ignoreAccidentalThreads && originalMessage && originalMessage.cleanContent) { - const cleaned = originalMessage.cleanContent.replace(/[^a-z\s]/gi, '').toLowerCase().trim(); - if (accidentalThreadMessages.includes(cleaned)) { - console.log('[NOTE] Skipping thread creation for message:', originalMessage.cleanContent); - return null; - } +/** + * Creates a new modmail thread for the specified user + * @param {Eris.User} user + * @returns {Promise} + * @throws {Error} + */ +async function createNewThreadForUser(user) { + const existingThread = await findOpenThreadByUserId(user.id); + if (existingThread) { + throw new Error('Attempted to create a new thread for a user with an existing open thread!'); } // Use the user's name+discrim for the thread channel's name @@ -126,7 +50,7 @@ async function getOpenThreadForUser(user, allowCreate = true, originalMessage = // Attempt to create the inbox channel for this thread let createdChannel; try { - createdChannel = await getUtils().getInboxGuild().createChannel(channelName); + 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}); @@ -137,7 +61,7 @@ async function getOpenThreadForUser(user, allowCreate = true, originalMessage = } // Save the new thread in the database - const newThreadId = await create({ + const newThreadId = await createThreadInDB({ status: THREAD_STATUS.OPEN, user_id: user.id, user_name: `${user.username}#${user.discriminator}`, @@ -145,10 +69,28 @@ async function getOpenThreadForUser(user, allowCreate = true, originalMessage = created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss') }); - const newThreadObj = new Thread(newThread); - newThreadObj._wasCreated = true; + const newThread = await findById(newThreadId); - return newThreadObj; + // Post some info to the beginning of the new thread + const mainGuild = utils.getMainGuild(); + const member = (mainGuild ? mainGuild.members.get(user.id) : null); + if (! member) console.log(`[INFO] Member ${user.id} not found in main guild ${config.mainGuildId}`); + + 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 (mainGuildNickname == null) mainGuildNickname = 'UNKNOWN'; + + const userLogCount = await getClosedThreadCountByUserId(user.id); + const accountAge = humanizeDuration(Date.now() - user.createdAt, {largest: 2}); + const infoHeader = `ACCOUNT AGE **${accountAge}**, ID **${user.id}**, NICKNAME **${mainGuildNickname}**, LOGS **${userLogCount}**\n-------------------------------`; + + await newThread.postSystemMessage(infoHeader); + + // Return the thread + return newThread; } /** @@ -156,32 +98,35 @@ async function getOpenThreadForUser(user, allowCreate = true, originalMessage = * @param {Object} data * @returns {Promise} The ID of the created thread */ -async function create(data) { +async function createThreadInDB(data) { const threadId = uuid.v4(); const now = moment.utc().format('YYYY-MM-DD HH:mm:ss'); const finalData = Object.assign({created_at: now, is_legacy: 0}, data, {id: threadId}); - await knex('threads').insert(newThread); + await knex('threads').insert(finalData); return threadId; } -async function addThreadMessage(threadId, messageType, user, body) { - return knex('thread_messages').insert({ - thread_id: threadId, - message_type: messageType, - user_id: (user ? user.id : 0), - user_name: (user ? `${user.username}#${user.discriminator}` : ''), - body, - created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss') - }); +/** + * @param {String} id + * @returns {Promise} + */ +async function findById(id) { + const row = await knex('threads') + .where('id', id) + .first(); + + if (! row) return null; + + return new Thread(row); } /** * @param {String} channelId * @returns {Promise} */ -async function getByChannelId(channelId) { +async function findByChannelId(channelId) { const thread = await knex('threads') .where('channel_id', channelId) .first(); @@ -190,24 +135,34 @@ async function getByChannelId(channelId) { } /** - * Deletes the modmail thread for the given channel id - * @param {String} channelId - * @returns {Promise} + * @param {String} userId + * @returns {Promise} */ -async function closeByChannelId(channelId) { - await knex('threads') - .where('channel_id', channelId) - .update({ - status: THREAD_STATUS.CLOSED - }); +async function getClosedThreadsByUserId(userId) { + const threads = await knex('threads') + .where('status', THREAD_STATUS.CLOSED) + .where('user_id', userId) + .select(); + + return threads.map(thread => new Thread(thread)); +} + +/** + * @param {String} userId + * @returns {Promise} + */ +async function getClosedThreadCountByUserId(userId) { + const row = await knex('threads') + .where('status', THREAD_STATUS.CLOSED) + .where('user_id', userId) + .first(knex.raw('COUNT(id) AS thread_count')); + + return parseInt(row.thread_count, 10); } module.exports = { - getOpenThreadForUser, - getByChannelId, - closeByChannelId, - create, - - THREAD_STATUS, - THREAD_MESSAGE_TYPE, + findOpenThreadByUserId, + findByChannelId, + createNewThreadForUser, + getClosedThreadsByUserId, }; diff --git a/src/index.js b/src/index.js index 58cc42c..8c30a8f 100644 --- a/src/index.js +++ b/src/index.js @@ -36,7 +36,7 @@ process.on('unhandledRejection', err => { console.log('=== LEGACY DATA MIGRATION FINISHED ==='); console.log(''); console.log('IMPORTANT: After the bot starts, please verify that all logs, threads, blocked users, and snippets are still working correctly.'); - console.log(`Once you've done that, feel free to delete the following legacy files/directories:`); + console.log('Once you\'ve done that, feel free to delete the following legacy files/directories:'); console.log(''); console.log('FILE: ' + path.resolve(relativeDbDir, 'threads.json')); console.log('FILE: ' + path.resolve(relativeDbDir, 'blocked.json')); @@ -47,5 +47,5 @@ process.on('unhandledRejection', err => { } // Start the bot - // main.start(); + main.start(); })(); diff --git a/src/legacy/legacyMigrator.js b/src/legacy/legacyMigrator.js index 368a1ef..44d3213 100644 --- a/src/legacy/legacyMigrator.js +++ b/src/legacy/legacyMigrator.js @@ -82,7 +82,7 @@ async function migrateOpenThreads() { is_legacy: 1 }; - return threads.create(newThread); + return threads.createThreadInDB(newThread); }); return Promise.all(promises); diff --git a/src/main.js b/src/main.js index 2d9de98..7a80148 100644 --- a/src/main.js +++ b/src/main.js @@ -1,7 +1,5 @@ -const fs = require('fs'); const Eris = require('eris'); const moment = require('moment'); -const humanizeDuration = require('humanize-duration'); const config = require('./config'); const bot = require('./bot'); @@ -9,32 +7,72 @@ const Queue = require('./queue'); const utils = require('./utils'); const blocked = require('./data/blocked'); const threads = require('./data/threads'); -const attachments = require('./data/attachments'); const snippets = require('./data/snippets'); const webserver = require('./webserver'); const greeting = require('./greeting'); +const Thread = require('./data/Thread'); 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); +} + // 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'); }); -// 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 (! utils.messageIsOnInboxServer(msg)) return; - if (! utils.isStaff(msg)) return; - if (msg.author.bot) return; - if (msg.content.startsWith(config.prefix) || msg.content.startsWith(config.snippetPrefix)) return; +// Handle moderator messages in thread channels +bot.on('messageCreate', async msg => { + if (! utils.messageIsOnInboxServer(msg)) return; + if (! utils.isStaff(msg)) return; + if (msg.author.bot) return; + if (msg.content.startsWith(config.prefix) || msg.content.startsWith(config.snippetPrefix)) return; - reply(msg, msg.content.trim(), config.alwaysReplyAnon || false); - }); -} + const thread = await threads.findByChannelId(msg.channel.id); + if (! thread) return; -// "Bot was mentioned in #general-discussion" + if (config.alwaysReply) { + // AUTO-REPLY: If config.alwaysReply is enabled, send all chat messages in thread channels as replies + await thread.replyToUser(msg.member, msg.content.trim(), msg.attachments, config.alwaysReplyAnon || false); + 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 + }); + } +}); + +// If the bot is mentioned on the main server, post a log message about it bot.on('messageCreate', async msg => { if (! utils.messageIsOnMainServer(msg)) return; if (! msg.mentions.some(user => user.id === bot.user.id)) return; @@ -58,79 +96,14 @@ bot.on('messageCreate', async msg => { if (await blocked.isBlocked(msg.author.id)) return; - // Download and save copies of attachments in the background - const attachmentSavePromise = attachments.saveAttachmentsInMessage(msg); - - 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(async () => { - let thread; - - // Find the corresponding modmail thread - try { - thread = await threads.getOpenThreadForUser(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: -\`\`\`${msg.cleanContent}\`\`\``); - return; - } - + let thread = await threads.findOpenThreadByUserId(msg.author.id); if (! thread) { - // If there's no thread returned, this message was probably ignored (e.g. due to a common word) - // TODO: Move that logic here instead? - return; + thread = await threads.createNewThreadForUser(msg.author, msg); } - 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}`); - - 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 (mainGuildNickname == null) mainGuildNickname = 'UNKNOWN'; - - 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-------------------------------`; - - await bot.createMessage(thread.channelId, infoHeader); - - // Ping mods of the new thread - await 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`); - }); - } - - const timestamp = utils.getTimestamp(); - const attachmentsPendingStr = '\n\n*Attachments pending...*'; - - let content = msg.content; - if (msg.attachments.length > 0) content += attachmentsPendingStr; - - const createdMsg = await bot.createMessage(thread.channelId, `[${timestamp}] « **${msg.author.username}#${msg.author.discriminator}:** ${content}`); - - 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)); - } + thread.receiveUserReply(msg); }); }); @@ -150,122 +123,39 @@ bot.on('messageUpdate', async (msg, oldMessage) => { // Ignore bogus edit events with no changes if (newContent.trim() === oldContent.trim()) return; - const thread = await threads.getOpenThreadForUser(msg.author); + 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); }); -/** - * 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; - - await attachments.saveAttachmentsInMessage(msg); - - const dmChannel = await bot.getDMChannel(thread.userId); - - let modUsername, logModUsername; - const mainRole = utils.getMainRole(msg.member); - - 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; - } - - let content = `**${modUsername}:** ${text}`; - let logContent = `**${logModUsername}:** ${text}`; - - 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 (attachmentUrl) { - content += `\n\n**Attachment:** ${attachmentUrl}`; - logContent += `\n\n**Attachment:** ${attachmentUrl}`; - } - - // 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 -utils.addInboxCommand('reply', (msg, args) => { +addInboxServerCommand('reply', (msg, args, thread) => { + if (! thread) return; const text = args.join(' ').trim(); - reply(msg, text, false); + thread.replyToUser(msg.member, text, msg.attachments, false); }); bot.registerCommandAlias('r', 'reply'); // Anonymous replies only show the role, not the username -utils.addInboxCommand('anonreply', (msg, args) => { +addInboxServerCommand('anonreply', (msg, args, thread) => { + if (! thread) return; const text = args.join(' ').trim(); - reply(msg, text, true); + thread.replyToUser(msg.member, text, msg.attachments, true); }); bot.registerCommandAlias('ar', 'anonreply'); // 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) => { +addInboxServerCommand('close', async (msg, args, thread) => { if (! thread) return; - - await msg.channel.createMessage('Saving logs and closing channel...'); - - 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'; - - const logFilename = await logs.getNewLogFile(thread.userId); - await logs.saveLogFile(logFilename, log); - - 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.closeByChannelId(thread.channelId); - msg.channel.delete(); + thread.close(); }); -utils.addInboxCommand('block', (msg, args, thread) => { +addInboxServerCommand('block', (msg, args, thread) => { async function block(userId) { await blocked.block(userId); msg.channel.createMessage(`Blocked <@${userId}> (id ${userId}) from modmail`); @@ -282,7 +172,7 @@ utils.addInboxCommand('block', (msg, args, thread) => { } }); -utils.addInboxCommand('unblock', (msg, args, thread) => { +addInboxServerCommand('unblock', (msg, args, thread) => { async function unblock(userId) { await blocked.unblock(userId); msg.channel.createMessage(`Unblocked <@${userId}> (id ${userId}) from modmail`); @@ -299,15 +189,16 @@ utils.addInboxCommand('unblock', (msg, args, thread) => { } }); -utils.addInboxCommand('logs', (msg, args, thread) => { +addInboxServerCommand('logs', (msg, args, thread) => { async function getLogs(userId) { - const infos = await logs.getLogsWithUrlByUserId(userId); - let message = `**Log files for <@${userId}>:**\n`; + const userThreads = await threads.getClosedThreadsByUserId(userId); + const threadLines = await Promise.all(userThreads.map(async thread => { + const logUrl = await thread.getLogUrl(); + const formattedDate = moment.utc(thread.created_at).format('MMM Do [at] HH:mm [UTC]'); + return `\`${formattedDate}\`: <${logUrl}>`; + })); - 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'); + const message = `**Log files for <@${userId}>:**\n${threadLines.join('\n')}`; // Send the list of logs in chunks of 15 lines per message const lines = message.split('\n'); @@ -347,7 +238,7 @@ bot.on('messageCreate', async msg => { }); // Show or add a snippet -utils.addInboxCommand('snippet', async (msg, args) => { +addInboxServerCommand('snippet', async (msg, args) => { const shortcut = args[0]; if (! shortcut) return @@ -376,7 +267,7 @@ utils.addInboxCommand('snippet', async (msg, args) => { bot.registerCommandAlias('s', 'snippet'); -utils.addInboxCommand('delete_snippet', async (msg, args) => { +addInboxServerCommand('delete_snippet', async (msg, args) => { const shortcut = args[0]; if (! shortcut) return; @@ -392,7 +283,7 @@ utils.addInboxCommand('delete_snippet', async (msg, args) => { bot.registerCommandAlias('ds', 'delete_snippet'); -utils.addInboxCommand('edit_snippet', async (msg, args) => { +addInboxServerCommand('edit_snippet', async (msg, args) => { const shortcut = args[0]; if (! shortcut) return; @@ -413,7 +304,7 @@ utils.addInboxCommand('edit_snippet', async (msg, args) => { bot.registerCommandAlias('es', 'edit_snippet'); -utils.addInboxCommand('snippets', async msg => { +addInboxServerCommand('snippets', async msg => { const allSnippets = await snippets.all(); const shortcuts = Object.keys(allSnippets); shortcuts.sort(); @@ -424,7 +315,7 @@ utils.addInboxCommand('snippets', async msg => { module.exports = { start() { bot.connect(); - webserver.run(); + // webserver.run(); greeting.init(bot); } }; diff --git a/src/utils.js b/src/utils.js index 249c1be..c2c27ae 100644 --- a/src/utils.js +++ b/src/utils.js @@ -2,7 +2,6 @@ const Eris = require('eris'); const bot = require('./bot'); const moment = require('moment'); const publicIp = require('public-ip'); -const threads = require('./data/threads'); const attachments = require('./data/attachments'); const config = require('./config'); @@ -86,23 +85,6 @@ function messageIsOnMainServer(msg) { 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} @@ -155,16 +137,15 @@ function disableLinkPreviews(str) { /** * Returns a URL to the bot's web server * @param {String} path - * @returns {String} + * @returns {Promise} */ -function getSelfUrl(path = '') { +async function getSelfUrl(path = '') { if (config.url) { - return Promise.resolve(`${config.url}/${path}`); + return `${config.url}/${path}`; } else { const port = config.port || 8890; - return publicIp.v4().then(ip => { - return `http://${ip}:${port}/${path}`; - }); + const ip = await publicIp.v4(); + return `http://${ip}:${port}/${path}`; } } @@ -206,7 +187,6 @@ module.exports = { isStaff, messageIsOnInboxServer, messageIsOnMainServer, - addInboxCommand, formatAttachment,