diff options
Diffstat (limited to 'src/data')
| -rw-r--r-- | src/data/Snippet.js | 15 | ||||
| -rw-r--r-- | src/data/Thread.js | 468 | ||||
| -rw-r--r-- | src/data/ThreadMessage.js | 20 | ||||
| -rw-r--r-- | src/data/attachments.js | 202 | ||||
| -rw-r--r-- | src/data/blocked.js | 94 | ||||
| -rw-r--r-- | src/data/constants.js | 55 | ||||
| -rw-r--r-- | src/data/snippets.js | 58 | ||||
| -rw-r--r-- | src/data/threads.js | 381 | ||||
| -rw-r--r-- | src/data/updates.js | 115 |
9 files changed, 1408 insertions, 0 deletions
diff --git a/src/data/Snippet.js b/src/data/Snippet.js new file mode 100644 index 0000000..4d5b684 --- /dev/null +++ b/src/data/Snippet.js @@ -0,0 +1,15 @@ +const utils = require("../utils"); + +/** + * @property {String} trigger + * @property {String} body + * @property {String} created_by + * @property {String} created_at + */ +class Snippet { + constructor(props) { + utils.setDataModelProps(this, props); + } +} + +module.exports = Snippet; diff --git a/src/data/Thread.js b/src/data/Thread.js new file mode 100644 index 0000000..23e9e4a --- /dev/null +++ b/src/data/Thread.js @@ -0,0 +1,468 @@ +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'); + +/** + * @property {String} id + * @property {Number} status + * @property {String} user_id + * @property {String} user_name + * @property {String} channel_id + * @property {String} scheduled_close_at + * @property {String} scheduled_close_id + * @property {String} scheduled_close_name + * @property {Number} scheduled_close_silent + * @property {String} alert_id + * @property {String} created_at + */ +class Thread { + constructor(props) { + utils.setDataModelProps(this, props); + } + + /** + * @param {Eris~Member} moderator + * @param {String} text + * @param {Eris~MessageFile[]} replyAttachments + * @param {Boolean} isAnonymous + * @returns {Promise<boolean>} Whether we were able to send the reply + */ + async replyToUser(moderator, text, replyAttachments = [], isAnonymous = false) { + // 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; + + if (config.threadTimestamps) { + const timestamp = utils.getTimestamp(); + threadContent = `[${timestamp}] » ${threadContent}`; + } + + // Prepare attachments, if any + let files = []; + + if (replyAttachments.length > 0) { + for (const attachment of replyAttachments) { + let savedAttachment; + + await Promise.all([ + attachments.attachmentToDiscordFileObject(attachment).then(file => { + files.push(file); + }), + attachments.saveAttachment(attachment).then(result => { + savedAttachment = result; + }) + ]); + + logContent += `\n\n**Attachment:** ${savedAttachment.url}`; + } + } + + // Send the reply DM + let dmMessage; + try { + dmMessage = await this.postToUser(dmContent, files); + } catch (e) { + await this.addThreadMessageToDB({ + message_type: THREAD_MESSAGE_TYPE.COMMAND, + user_id: moderator.id, + user_name: logModUsername, + body: logContent + }); + + await this.postSystemMessage(`Error while replying to user: ${e.message}`); + + return false; + } + + // Send the reply to the modmail thread + await this.postToThreadChannel(threadContent, files); + + // 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, + is_anonymous: (isAnonymous ? 1 : 0), + dm_message_id: dmMessage.id + }); + + if (this.scheduled_close_at) { + await this.cancelScheduledClose(); + await this.postSystemMessage(`Cancelling scheduled closing of this thread due to new reply`); + } + + return true; + } + + /** + * @param {Eris~Message} msg + * @returns {Promise<void>} + */ + async receiveUserReply(msg) { + let content = msg.content; + if (msg.content.trim() === '' && msg.embeds.length) { + content = '<message contains embeds>'; + } + + let threadContent = `**${msg.author.username}#${msg.author.discriminator}:** ${content}`; + let logContent = msg.content; + + if (config.threadTimestamps) { + const timestamp = utils.getTimestamp(msg.timestamp, 'x'); + threadContent = `[${timestamp}] « ${threadContent}`; + } + + // Prepare attachments, if any + let attachmentFiles = []; + + for (const attachment of msg.attachments) { + const savedAttachment = await attachments.saveAttachment(attachment); + + // Forward small attachments (<2MB) as attachments, just link to larger ones + const formatted = '\n\n' + await utils.formatAttachment(attachment, savedAttachment.url); + logContent += formatted; // Logs always contain the link + + if (config.relaySmallAttachmentsAsAttachments && attachment.size <= 1024 * 1024 * 2) { + const file = await attachments.attachmentToDiscordFileObject(attachment); + attachmentFiles.push(file); + } else { + threadContent += formatted; + } + } + + await this.postToThreadChannel(threadContent, attachmentFiles); + 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, + is_anonymous: 0, + dm_message_id: msg.id + }); + + if (this.scheduled_close_at) { + await this.cancelScheduledClose(); + await this.postSystemMessage(`<@!${this.scheduled_close_id}> Thread that was scheduled to be closed got a new reply. Cancelling.`); + } + + if (this.alert_id) { + await this.setAlert(null); + await this.postSystemMessage(`<@!${this.alert_id}> New message from ${this.user_name}`); + } + } + + /** + * @returns {Promise<PrivateChannel>} + */ + getDMChannel() { + return bot.getDMChannel(this.user_id); + } + + /** + * @param {String} text + * @param {Eris~MessageFile|Eris~MessageFile[]} file + * @returns {Promise<Eris~Message>} + * @throws Error + */ + async postToUser(text, file = null) { + // Try to open a DM channel with the user + const dmChannel = await this.getDMChannel(); + if (! dmChannel) { + throw new Error('Could not open DMs with the user. They may have blocked the bot or set their privacy settings higher.'); + } + + // Send the DM + const chunks = utils.chunk(text, 2000); + const messages = await Promise.all(chunks.map((chunk, i) => { + return dmChannel.createMessage( + chunk, + (i === chunks.length - 1 ? file : undefined) // Only send the file with the last message + ); + })); + return messages[0]; + } + + /** + * @returns {Promise<Eris~Message>} + */ + async postToThreadChannel(...args) { + try { + if (typeof args[0] === 'string') { + const chunks = utils.chunk(args[0], 2000); + const messages = await Promise.all(chunks.map((chunk, i) => { + const rest = (i === chunks.length - 1 ? args.slice(1) : []); // Only send the rest of the args (files, embeds) with the last message + return bot.createMessage(this.channel_id, chunk, ...rest); + })); + return messages[0]; + } else { + return bot.createMessage(this.channel_id, ...args); + } + } catch (e) { + // Channel not found + if (e.code === 10003) { + console.log(`[INFO] Failed to send message to thread channel for ${this.user_name} because the channel no longer exists. Auto-closing the thread.`); + this.close(true); + } else { + throw e; + } + } + } + + /** + * @param {String} text + * @param {*} args + * @returns {Promise<void>} + */ + async postSystemMessage(text, ...args) { + const msg = await this.postToThreadChannel(text, ...args); + await this.addThreadMessageToDB({ + message_type: THREAD_MESSAGE_TYPE.SYSTEM, + user_id: null, + user_name: '', + body: typeof text === 'string' ? text : text.content, + is_anonymous: 0, + dm_message_id: msg.id + }); + } + + /** + * @param {*} args + * @returns {Promise<void>} + */ + async postNonLogMessage(...args) { + await this.postToThreadChannel(...args); + } + + /** + * @param {Eris.Message} msg + * @returns {Promise<void>} + */ + 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, + dm_message_id: msg.id + }); + } + + async saveCommandMessage(msg) { + return this.addThreadMessageToDB({ + message_type: THREAD_MESSAGE_TYPE.COMMAND, + user_id: msg.author.id, + user_name: `${msg.author.username}#${msg.author.discriminator}`, + body: msg.content, + is_anonymous: 0, + dm_message_id: msg.id + }); + } + + /** + * @param {Eris.Message} msg + * @returns {Promise<void>} + */ + async updateChatMessage(msg) { + await knex('thread_messages') + .where('thread_id', this.id) + .where('dm_message_id', msg.id) + .update({ + body: msg.content + }); + } + + /** + * @param {String} messageId + * @returns {Promise<void>} + */ + async deleteChatMessage(messageId) { + await knex('thread_messages') + .where('thread_id', this.id) + .where('dm_message_id', messageId) + .delete(); + } + + /** + * @param {Object} data + * @returns {Promise<void>} + */ + async addThreadMessageToDB(data) { + 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<ThreadMessage[]>} + */ + 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<void>} + */ + async close(suppressSystemMessage = false, silent = false) { + if (! suppressSystemMessage) { + console.log(`Closing thread ${this.id}`); + + if (silent) { + await this.postSystemMessage('Closing thread silently...'); + } else { + await this.postSystemMessage('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) { + console.log(`Deleting channel ${this.channel_id}`); + await channel.delete('Thread closed'); + } + } + + /** + * @param {String} time + * @param {Eris~User} user + * @param {Number} silent + * @returns {Promise<void>} + */ + async scheduleClose(time, user, silent) { + await knex('threads') + .where('id', this.id) + .update({ + scheduled_close_at: time, + scheduled_close_id: user.id, + scheduled_close_name: user.username, + scheduled_close_silent: silent + }); + } + + /** + * @returns {Promise<void>} + */ + async cancelScheduledClose() { + await knex('threads') + .where('id', this.id) + .update({ + scheduled_close_at: null, + scheduled_close_id: null, + scheduled_close_name: null, + scheduled_close_silent: null + }); + } + + /** + * @returns {Promise<void>} + */ + async suspend() { + await knex('threads') + .where('id', this.id) + .update({ + status: THREAD_STATUS.SUSPENDED, + scheduled_suspend_at: null, + scheduled_suspend_id: null, + scheduled_suspend_name: null + }); + } + + /** + * @returns {Promise<void>} + */ + async unsuspend() { + await knex('threads') + .where('id', this.id) + .update({ + status: THREAD_STATUS.OPEN + }); + } + + /** + * @param {String} time + * @param {Eris~User} user + * @returns {Promise<void>} + */ + async scheduleSuspend(time, user) { + await knex('threads') + .where('id', this.id) + .update({ + scheduled_suspend_at: time, + scheduled_suspend_id: user.id, + scheduled_suspend_name: user.username + }); + } + + /** + * @returns {Promise<void>} + */ + async cancelScheduledSuspend() { + await knex('threads') + .where('id', this.id) + .update({ + scheduled_suspend_at: null, + scheduled_suspend_id: null, + scheduled_suspend_name: null + }); + } + + /** + * @param {String} userId + * @returns {Promise<void>} + */ + async setAlert(userId) { + await knex('threads') + .where('id', this.id) + .update({ + alert_id: userId + }); + } + + /** + * @returns {Promise<String>} + */ + getLogUrl() { + return utils.getSelfUrl(`logs/${this.id}`); + } +} + +module.exports = Thread; diff --git a/src/data/ThreadMessage.js b/src/data/ThreadMessage.js new file mode 100644 index 0000000..2704287 --- /dev/null +++ b/src/data/ThreadMessage.js @@ -0,0 +1,20 @@ +const utils = require("../utils"); + +/** + * @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} dm_message_id + * @property {String} created_at + */ +class ThreadMessage { + constructor(props) { + utils.setDataModelProps(this, props); + } +} + +module.exports = ThreadMessage; diff --git a/src/data/attachments.js b/src/data/attachments.js new file mode 100644 index 0000000..40b13b0 --- /dev/null +++ b/src/data/attachments.js @@ -0,0 +1,202 @@ +const Eris = require('eris'); +const fs = require('fs'); +const https = require('https'); +const {promisify} = require('util'); +const tmp = require('tmp'); +const config = require('../config'); +const utils = require('../utils'); +const mv = promisify(require('mv')); + +const getUtils = () => require('../utils'); + +const access = promisify(fs.access); +const readFile = promisify(fs.readFile); + +const localAttachmentDir = config.attachmentDir || `${__dirname}/../../attachments`; + +const attachmentSavePromises = {}; + +const attachmentStorageTypes = {}; + +function getErrorResult(msg = null) { + return { + url: `Attachment could not be saved${msg ? ': ' + msg : ''}`, + failed: true + }; +} + +/** + * Attempts to download and save the given attachement + * @param {Object} attachment + * @param {Number=0} tries + * @returns {Promise<{ url: string }>} + */ +async function saveLocalAttachment(attachment) { + const targetPath = getLocalAttachmentPath(attachment.id); + + try { + // If the file already exists, resolve immediately + await access(targetPath); + const url = await getLocalAttachmentUrl(attachment.id, attachment.filename); + return { url }; + } catch (e) {} + + // Download the attachment + const downloadResult = await downloadAttachment(attachment); + + // Move the temp file to the attachment folder + await mv(downloadResult.path, targetPath); + + // Resolve the attachment URL + const url = await getLocalAttachmentUrl(attachment.id, attachment.filename); + + return { url }; +} + +/** + * @param {Object} attachment + * @param {Number} tries + * @returns {Promise<{ path: string, cleanup: function }>} + */ +function downloadAttachment(attachment, tries = 0) { + return new Promise((resolve, reject) => { + if (tries > 3) { + console.error('Attachment download failed after 3 tries:', attachment); + reject('Attachment download failed after 3 tries'); + return; + } + + tmp.file((err, filepath, fd, cleanupCallback) => { + const writeStream = fs.createWriteStream(filepath); + + https.get(attachment.url, (res) => { + res.pipe(writeStream); + writeStream.on('finish', () => { + writeStream.end(); + resolve({ + path: filepath, + cleanup: cleanupCallback + }); + }); + }).on('error', (err) => { + fs.unlink(filepath); + console.error('Error downloading attachment, retrying'); + resolve(downloadAttachment(attachment, tries++)); + }); + }); + }); +} + +/** + * Returns the filesystem path for the given attachment id + * @param {String} attachmentId + * @returns {String} + */ +function getLocalAttachmentPath(attachmentId) { + return `${localAttachmentDir}/${attachmentId}`; +} + +/** + * Returns the self-hosted URL to the given attachment ID + * @param {String} attachmentId + * @param {String=null} desiredName Custom name for the attachment as a hint for the browser + * @returns {Promise<String>} + */ +function getLocalAttachmentUrl(attachmentId, desiredName = null) { + if (desiredName == null) desiredName = 'file.bin'; + return getUtils().getSelfUrl(`attachments/${attachmentId}/${desiredName}`); +} + +/** + * @param {Object} attachment + * @returns {Promise<{ url: string }>} + */ +async function saveDiscordAttachment(attachment) { + if (attachment.size > 1024 * 1024 * 8) { + return getErrorResult('attachment too large (max 8MB)'); + } + + const attachmentChannelId = config.attachmentStorageChannelId; + const inboxGuild = utils.getInboxGuild(); + + if (! inboxGuild.channels.has(attachmentChannelId)) { + throw new Error('Attachment storage channel not found!'); + } + + const attachmentChannel = inboxGuild.channels.get(attachmentChannelId); + if (! (attachmentChannel instanceof Eris.TextChannel)) { + throw new Error('Attachment storage channel must be a text channel!'); + } + + const file = await attachmentToDiscordFileObject(attachment); + const savedAttachment = await createDiscordAttachmentMessage(attachmentChannel, file); + if (! savedAttachment) return getErrorResult(); + + return { url: savedAttachment.url }; +} + +async function createDiscordAttachmentMessage(channel, file, tries = 0) { + tries++; + + try { + const attachmentMessage = await channel.createMessage(undefined, file); + return attachmentMessage.attachments[0]; + } catch (e) { + if (tries > 3) { + console.error(`Attachment storage message could not be created after 3 tries: ${e.message}`); + return; + } + + return createDiscordAttachmentMessage(channel, file, tries); + } +} + +/** + * Turns the given attachment into a file object that can be sent forward as a new attachment + * @param {Object} attachment + * @returns {Promise<{file, name: string}>} + */ +async function attachmentToDiscordFileObject(attachment) { + const downloadResult = await downloadAttachment(attachment); + const data = await readFile(downloadResult.path); + downloadResult.cleanup(); + return {file: data, name: attachment.filename}; +} + +/** + * Saves the given attachment based on the configured storage system + * @param {Object} attachment + * @returns {Promise<{ url: string }>} + */ +function saveAttachment(attachment) { + if (attachmentSavePromises[attachment.id]) { + return attachmentSavePromises[attachment.id]; + } + + if (attachmentStorageTypes[config.attachmentStorage]) { + attachmentSavePromises[attachment.id] = Promise.resolve(attachmentStorageTypes[config.attachmentStorage](attachment)); + } else { + throw new Error(`Unknown attachment storage option: ${config.attachmentStorage}`); + } + + attachmentSavePromises[attachment.id].then(() => { + delete attachmentSavePromises[attachment.id]; + }); + + return attachmentSavePromises[attachment.id]; +} + +function addStorageType(name, handler) { + attachmentStorageTypes[name] = handler; +} + +attachmentStorageTypes.local = saveLocalAttachment; +attachmentStorageTypes.discord = saveDiscordAttachment; + +module.exports = { + getLocalAttachmentPath, + attachmentToDiscordFileObject, + saveAttachment, + addStorageType, + downloadAttachment +}; diff --git a/src/data/blocked.js b/src/data/blocked.js new file mode 100644 index 0000000..81c1214 --- /dev/null +++ b/src/data/blocked.js @@ -0,0 +1,94 @@ +const moment = require('moment'); +const knex = require('../knex'); + +/** + * @param {String} userId + * @returns {Promise<{ isBlocked: boolean, expiresAt: string }>} + */ +async function getBlockStatus(userId) { + const row = await knex('blocked_users') + .where('user_id', userId) + .first(); + + return { + isBlocked: !! row, + expiresAt: row && row.expires_at + }; +} + +/** + * Checks whether userId is blocked + * @param {String} userId + * @returns {Promise<Boolean>} + */ +async function isBlocked(userId) { + return (await getBlockStatus(userId)).isBlocked; +} + +/** + * Blocks the given userId + * @param {String} userId + * @param {String} userName + * @param {String} blockedBy + * @returns {Promise} + */ +async function block(userId, userName = '', blockedBy = null, expiresAt = null) { + if (await isBlocked(userId)) return; + + return knex('blocked_users') + .insert({ + user_id: userId, + user_name: userName, + blocked_by: blockedBy, + blocked_at: moment.utc().format('YYYY-MM-DD HH:mm:ss'), + expires_at: expiresAt + }); +} + +/** + * Unblocks the given userId + * @param {String} userId + * @returns {Promise} + */ +async function unblock(userId) { + return knex('blocked_users') + .where('user_id', userId) + .delete(); +} + +/** + * Updates the expiry time of the block for the given userId + * @param {String} userId + * @param {String} expiresAt + * @returns {Promise<void>} + */ +async function updateExpiryTime(userId, expiresAt) { + return knex('blocked_users') + .where('user_id', userId) + .update({ + expires_at: expiresAt + }); +} + +/** + * @returns {String[]} + */ +async function getExpiredBlocks() { + const now = moment.utc().format('YYYY-MM-DD HH:mm:ss'); + + const blocks = await knex('blocked_users') + .whereNotNull('expires_at') + .where('expires_at', '<=', now) + .select(); + + return blocks.map(block => block.user_id); +} + +module.exports = { + getBlockStatus, + isBlocked, + block, + unblock, + updateExpiryTime, + getExpiredBlocks, +}; diff --git a/src/data/constants.js b/src/data/constants.js new file mode 100644 index 0000000..13a30e8 --- /dev/null +++ b/src/data/constants.js @@ -0,0 +1,55 @@ +module.exports = { + THREAD_STATUS: { + OPEN: 1, + CLOSED: 2, + SUSPENDED: 3 + }, + + THREAD_MESSAGE_TYPE: { + SYSTEM: 1, + CHAT: 2, + FROM_USER: 3, + TO_USER: 4, + LEGACY: 5, + COMMAND: 6 + }, + + ACCIDENTAL_THREAD_MESSAGES: [ + 'ok', + 'okay', + 'thanks', + 'ty', + 'k', + 'kk', + '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 new file mode 100644 index 0000000..a95b2b4 --- /dev/null +++ b/src/data/snippets.js @@ -0,0 +1,58 @@ +const moment = require('moment'); +const knex = require('../knex'); +const Snippet = require('./Snippet'); + +/** + * @param {String} trigger + * @returns {Promise<Snippet>} + */ +async function getSnippet(trigger) { + const snippet = await knex('snippets') + .where('trigger', trigger) + .first(); + + return (snippet ? new Snippet(snippet) : null); +} + +/** + * @param {String} trigger + * @param {String} body + * @returns {Promise<void>} + */ +async function addSnippet(trigger, body, createdBy = 0) { + if (await getSnippet(trigger)) return; + + return knex('snippets').insert({ + trigger, + body, + created_by: createdBy, + created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss') + }); +} + +/** + * @param {String} trigger + * @returns {Promise<void>} + */ +async function deleteSnippet(trigger) { + return knex('snippets') + .where('trigger', trigger) + .delete(); +} + +/** + * @returns {Promise<Snippet[]>} + */ +async function getAllSnippets() { + const snippets = await knex('snippets') + .select(); + + return snippets.map(s => new Snippet(s)); +} + +module.exports = { + get: getSnippet, + add: addSnippet, + del: deleteSnippet, + all: getAllSnippets, +}; diff --git a/src/data/threads.js b/src/data/threads.js new file mode 100644 index 0000000..6e700b0 --- /dev/null +++ b/src/data/threads.js @@ -0,0 +1,381 @@ +const {User, Member} = 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 updates = require('./updates'); + +const Thread = require('./Thread'); +const {THREAD_STATUS} = require('./constants'); + +const MINUTES = 60 * 1000; +const HOURS = 60 * MINUTES; + +/** + * @param {String} id + * @returns {Promise<Thread>} + */ +async function findById(id) { + const thread = await knex('threads') + .where('id', id) + .first(); + + return (thread ? new Thread(thread) : null); +} + +/** + * @param {String} userId + * @returns {Promise<Thread>} + */ +async function findOpenThreadByUserId(userId) { + const thread = await knex('threads') + .where('user_id', userId) + .where('status', THREAD_STATUS.OPEN) + .first(); + + return (thread ? new Thread(thread) : null); +} + +function getHeaderGuildInfo(member) { + return { + nickname: member.nick || member.user.username, + joinDate: humanizeDuration(Date.now() - member.joinedAt, {largest: 2, round: true}) + }; +} + +/** + * Creates a new modmail thread for the specified user + * @param {User} user + * @param {Member} member + * @param {Boolean} quiet If true, doesn't ping mentionRole or reply with responseMessage + * @returns {Promise<Thread|undefined>} + * @throws {Error} + */ +async function createNewThreadForUser(user, quiet = false, ignoreRequirements = false) { + 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!'); + } + + // If set in config, check that the user's account is old enough (time since they registered on Discord) + // If the account is too new, don't start a new thread and optionally reply to them with a message + if (config.requiredAccountAge && ! ignoreRequirements) { + if (user.createdAt > moment() - config.requiredAccountAge * HOURS){ + if (config.accountAgeDeniedMessage) { + const accountAgeDeniedMessage = utils.readMultilineConfigValue(config.accountAgeDeniedMessage); + const privateChannel = await user.getDMChannel(); + await privateChannel.createMessage(accountAgeDeniedMessage); + } + return; + } + } + + // Find which main guilds this user is part of + const mainGuilds = utils.getMainGuilds(); + const userGuildData = new Map(); + + for (const guild of mainGuilds) { + let member = guild.members.get(user.id); + + if (! member) { + try { + member = await bot.getRESTGuildMember(guild.id, user.id); + } catch (e) { + continue; + } + } + + if (member) { + userGuildData.set(guild.id, { guild, member }); + } + } + + // If set in config, check that the user has been a member of one of the main guilds long enough + // If they haven't, don't start a new thread and optionally reply to them with a message + if (config.requiredTimeOnServer && ! ignoreRequirements) { + // Check if the user joined any of the main servers a long enough time ago + // If we don't see this user on any of the main guilds (the size check below), assume we're just missing some data and give the user the benefit of the doubt + const isAllowed = userGuildData.size === 0 || Array.from(userGuildData.values()).some(({guild, member}) => { + return member.joinedAt < moment() - config.requiredTimeOnServer * MINUTES; + }); + + if (! isAllowed) { + if (config.timeOnServerDeniedMessage) { + const timeOnServerDeniedMessage = utils.readMultilineConfigValue(config.timeOnServerDeniedMessage); + const privateChannel = await user.getDMChannel(); + await privateChannel.createMessage(timeOnServerDeniedMessage); + } + return; + } + } + + // Use the user's name+discrim for the thread channel's name + // Channel names are particularly picky about what characters they allow, so we gotta do some clean-up + let cleanName = transliterate.slugify(user.username); + if (cleanName === '') cleanName = 'unknown'; + cleanName = cleanName.slice(0, 95); // Make sure the discrim fits + + const channelName = `${cleanName}-${user.discriminator}`; + + console.log(`[NOTE] Creating new thread channel ${channelName}`); + + // Figure out which category we should place the thread channel in + let newThreadCategoryId; + + if (config.categoryAutomation.newThreadFromGuild) { + // Categories for specific source guilds (in case of multiple main guilds) + for (const [guildId, categoryId] of Object.entries(config.categoryAutomation.newThreadFromGuild)) { + if (userGuildData.has(guildId)) { + newThreadCategoryId = categoryId; + break; + } + } + } + + if (! newThreadCategoryId && config.categoryAutomation.newThread) { + // Blanket category id for all new threads (also functions as a fallback for the above) + newThreadCategoryId = config.categoryAutomation.newThread; + } + + // Attempt to create the inbox channel for this thread + let createdChannel; + try { + createdChannel = await utils.getInboxGuild().createChannel(channelName, null, 'New Modmail thread', newThreadCategoryId); + } catch (err) { + console.error(`Error creating modmail channel for ${user.username}#${user.discriminator}!`); + throw err; + } + + // Save the new thread in the database + const newThreadId = await createThreadInDB({ + status: THREAD_STATUS.OPEN, + user_id: user.id, + user_name: `${user.username}#${user.discriminator}`, + channel_id: createdChannel.id, + created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss') + }); + + const newThread = await findById(newThreadId); + let responseMessageError = null; + + if (! quiet) { + // Ping moderators of the new thread + if (config.mentionRole) { + await newThread.postNonLogMessage({ + content: `${utils.getInboxMention()}New modmail thread (${newThread.user_name})`, + disableEveryone: false + }); + } + + // Send auto-reply to the user + if (config.responseMessage) { + const responseMessage = utils.readMultilineConfigValue(config.responseMessage); + + try { + await newThread.postToUser(responseMessage); + } catch (err) { + responseMessageError = err; + } + } + } + + // Post some info to the beginning of the new thread + const infoHeaderItems = []; + + // Account age + const accountAge = humanizeDuration(Date.now() - user.createdAt, {largest: 2, round: true}); + infoHeaderItems.push(`ACCOUNT AGE **${accountAge}**`); + + // User id (and mention, if enabled) + if (config.mentionUserInThreadHeader) { + infoHeaderItems.push(`ID **${user.id}** (<@!${user.id}>)`); + } else { + infoHeaderItems.push(`ID **${user.id}**`); + } + + let infoHeader = infoHeaderItems.join(', '); + + // Guild member info + for (const [guildId, guildData] of userGuildData.entries()) { + const {nickname, joinDate} = getHeaderGuildInfo(guildData.member); + const headerItems = [ + `NICKNAME **${utils.escapeMarkdown(nickname)}**`, + `JOINED **${joinDate}** ago` + ]; + + if (guildData.member.voiceState.channelID) { + const voiceChannel = guildData.guild.channels.get(guildData.member.voiceState.channelID); + if (voiceChannel) { + headerItems.push(`VOICE CHANNEL **${utils.escapeMarkdown(voiceChannel.name)}**`); + } + } + + if (config.rolesInThreadHeader && guildData.member.roles.length) { + const roles = guildData.member.roles.map(roleId => guildData.guild.roles.get(roleId)).filter(Boolean); + headerItems.push(`ROLES **${roles.map(r => r.name).join(', ')}**`); + } + + const headerStr = headerItems.join(', '); + + if (mainGuilds.length === 1) { + infoHeader += `\n${headerStr}`; + } else { + infoHeader += `\n**[${utils.escapeMarkdown(guildData.guild.name)}]** ${headerStr}`; + } + } + + // Modmail history / previous logs + const userLogCount = await getClosedThreadCountByUserId(user.id); + if (userLogCount > 0) { + infoHeader += `\n\nThis user has **${userLogCount}** previous modmail threads. Use \`${config.prefix}logs\` to see them.`; + } + + infoHeader += '\n────────────────'; + + await newThread.postSystemMessage(infoHeader); + + if (config.updateNotifications) { + const availableUpdate = await updates.getAvailableUpdate(); + if (availableUpdate) { + await newThread.postNonLogMessage(`📣 New bot version available (${availableUpdate})`); + } + } + + // If there were errors sending a response to the user, note that + if (responseMessageError) { + await newThread.postSystemMessage(`**NOTE:** Could not send auto-response to the user. The error given was: \`${responseMessageError.message}\``); + } + + // Return the thread + return newThread; +} + +/** + * Creates a new thread row in the database + * @param {Object} data + * @returns {Promise<String>} The ID of the created thread + */ +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(finalData); + + return threadId; +} + +/** + * @param {String} channelId + * @returns {Promise<Thread>} + */ +async function findByChannelId(channelId) { + const thread = await knex('threads') + .where('channel_id', channelId) + .first(); + + return (thread ? new Thread(thread) : null); +} + +/** + * @param {String} channelId + * @returns {Promise<Thread>} + */ +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} channelId + * @returns {Promise<Thread>} + */ +async function findSuspendedThreadByChannelId(channelId) { + const thread = await knex('threads') + .where('channel_id', channelId) + .where('status', THREAD_STATUS.SUSPENDED) + .first(); + + return (thread ? new Thread(thread) : null); +} + +/** + * @param {String} userId + * @returns {Promise<Thread[]>} + */ +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<number>} + */ +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); +} + +async function findOrCreateThreadForUser(user) { + const existingThread = await findOpenThreadByUserId(user.id); + if (existingThread) return existingThread; + + return createNewThreadForUser(user); +} + +async function getThreadsThatShouldBeClosed() { + const now = moment.utc().format('YYYY-MM-DD HH:mm:ss'); + const threads = await knex('threads') + .where('status', THREAD_STATUS.OPEN) + .whereNotNull('scheduled_close_at') + .where('scheduled_close_at', '<=', now) + .whereNotNull('scheduled_close_at') + .select(); + + return threads.map(thread => new Thread(thread)); +} + +async function getThreadsThatShouldBeSuspended() { + const now = moment.utc().format('YYYY-MM-DD HH:mm:ss'); + const threads = await knex('threads') + .where('status', THREAD_STATUS.OPEN) + .whereNotNull('scheduled_suspend_at') + .where('scheduled_suspend_at', '<=', now) + .whereNotNull('scheduled_suspend_at') + .select(); + + return threads.map(thread => new Thread(thread)); +} + +module.exports = { + findById, + findOpenThreadByUserId, + findByChannelId, + findOpenThreadByChannelId, + findSuspendedThreadByChannelId, + createNewThreadForUser, + getClosedThreadsByUserId, + findOrCreateThreadForUser, + getThreadsThatShouldBeClosed, + getThreadsThatShouldBeSuspended, + createThreadInDB +}; diff --git a/src/data/updates.js b/src/data/updates.js new file mode 100644 index 0000000..a57bc62 --- /dev/null +++ b/src/data/updates.js @@ -0,0 +1,115 @@ +const url = require('url'); +const https = require('https'); +const moment = require('moment'); +const knex = require('../knex'); +const config = require('../config'); + +const UPDATE_CHECK_FREQUENCY = 12; // In hours +let updateCheckPromise = null; + +async function initUpdatesTable() { + const row = await knex('updates').first(); + if (! row) { + await knex('updates').insert({ + available_version: null, + last_checked: null, + }); + } +} + +/** + * Update current and available versions in the database. + * Only works when `repository` in package.json is set to a GitHub repository + * @returns {Promise<void>} + */ +async function refreshVersions() { + await initUpdatesTable(); + const { last_checked } = await knex('updates').first(); + + // Only refresh available version if it's been more than UPDATE_CHECK_FREQUENCY since our last check + if (last_checked != null && last_checked > moment.utc().subtract(UPDATE_CHECK_FREQUENCY, 'hours').format('YYYY-MM-DD HH:mm:ss')) return; + + const packageJson = require('../../package.json'); + const repositoryUrl = packageJson.repository && packageJson.repository.url; + if (! repositoryUrl) return; + + const parsedUrl = url.parse(repositoryUrl); + if (parsedUrl.hostname !== 'github.com') return; + + const [, owner, repo] = parsedUrl.pathname.split('/'); + if (! owner || ! repo) return; + + https.get( + { + hostname: 'api.github.com', + path: `/repos/${owner}/${repo}/tags`, + headers: { + 'User-Agent': `Modmail Bot (https://github.com/${owner}/${repo}) (${packageJson.version})` + } + }, + async res => { + if (res.statusCode !== 200) { + await knex('updates').update({ + last_checked: moment.utc().format('YYYY-MM-DD HH:mm:ss') + }); + console.warn(`[WARN] Got status code ${res.statusCode} when checking for available updates`); + return; + } + + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', async () => { + const parsed = JSON.parse(data); + if (! Array.isArray(parsed) || parsed.length === 0) return; + + const latestVersion = parsed[0].name; + await knex('updates').update({ + available_version: latestVersion, + last_checked: moment.utc().format('YYYY-MM-DD HH:mm:ss') + }); + }); + } + ); +} + +/** + * @param {String} a Version string, e.g. "2.20.0" + * @param {String} b Version string, e.g. "2.20.0" + * @returns {Number} 1 if version a is larger than b, -1 is version a is smaller than b, 0 if they are equal + */ +function compareVersions(a, b) { + const aParts = a.split('.'); + const bParts = b.split('.'); + for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { + let aPart = parseInt((aParts[i] || '0').match(/\d+/)[0] || '0', 10); + let bPart = parseInt((bParts[i] || '0').match(/\d+/)[0] || '0', 10); + if (aPart > bPart) return 1; + if (aPart < bPart) return -1; + } + return 0; +} + +async function getAvailableUpdate() { + await initUpdatesTable(); + + const packageJson = require('../../package.json'); + const currentVersion = packageJson.version; + const { available_version: availableVersion } = await knex('updates').first(); + if (availableVersion == null) return null; + if (currentVersion == null) return availableVersion; + + const versionDiff = compareVersions(currentVersion, availableVersion); + if (versionDiff === -1) return availableVersion; + + return null; +} + +async function refreshVersionsLoop() { + await refreshVersions(); + setTimeout(refreshVersionsLoop, UPDATE_CHECK_FREQUENCY * 60 * 60 * 1000); +} + +module.exports = { + getAvailableUpdate, + startVersionRefreshLoop: refreshVersionsLoop +}; |