diff options
| author | Sin-MacBook <[email protected]> | 2020-08-10 23:44:20 +0200 |
|---|---|---|
| committer | Sin-MacBook <[email protected]> | 2020-08-10 23:44:20 +0200 |
| commit | 2a53887abba882bf7b63aeda8dfa55fdb3ab8792 (patch) | |
| tree | ad7a95eb41faa6ff13c3142285cdc0eb3ca92183 /src | |
| download | modmail-2a53887abba882bf7b63aeda8dfa55fdb3ab8792.tar.xz modmail-2a53887abba882bf7b63aeda8dfa55fdb3ab8792.zip | |
clean this up when home
Diffstat (limited to 'src')
34 files changed, 3832 insertions, 0 deletions
diff --git a/src/bot.js b/src/bot.js new file mode 100644 index 0000000..2a1591a --- /dev/null +++ b/src/bot.js @@ -0,0 +1,9 @@ +const Eris = require('eris'); +const config = require('./config'); + +const bot = new Eris.Client(config.token, { + getAllUsers: true, + restMode: true, +}); + +module.exports = bot; diff --git a/src/commands.js b/src/commands.js new file mode 100644 index 0000000..59c6506 --- /dev/null +++ b/src/commands.js @@ -0,0 +1,130 @@ +const { CommandManager, defaultParameterTypes, TypeConversionError } = require('knub-command-manager'); +const config = require('./config'); +const utils = require('./utils'); +const threads = require('./data/threads'); + +module.exports = { + createCommandManager(bot) { + const manager = new CommandManager({ + prefix: config.prefix, + types: Object.assign({}, defaultParameterTypes, { + userId(value) { + const userId = utils.getUserMention(value); + if (! userId) throw new TypeConversionError(); + return userId; + }, + + delay(value) { + const ms = utils.convertDelayStringToMS(value); + if (ms === null) throw new TypeConversionError(); + return ms; + } + }) + }); + + const handlers = {}; + const aliasMap = new Map(); + + bot.on('messageCreate', async msg => { + if (msg.author.bot) return; + if (msg.author.id === bot.user.id) return; + if (! msg.content) return; + + const matchedCommand = await manager.findMatchingCommand(msg.content, { msg }); + if (matchedCommand === null) return; + if (matchedCommand.error !== undefined) { + utils.postError(msg.channel, matchedCommand.error); + return; + } + + const allArgs = {}; + for (const [name, arg] of Object.entries(matchedCommand.args)) { + allArgs[name] = arg.value; + } + for (const [name, opt] of Object.entries(matchedCommand.opts)) { + allArgs[name] = opt.value; + } + + handlers[matchedCommand.id](msg, allArgs); + }); + + /** + * Add a command that can be invoked anywhere + */ + const addGlobalCommand = (trigger, parameters, handler, commandConfig = {}) => { + let aliases = aliasMap.has(trigger) ? [...aliasMap.get(trigger)] : []; + if (commandConfig.aliases) aliases.push(...commandConfig.aliases); + + const cmd = manager.add(trigger, parameters, { ...commandConfig, aliases }); + handlers[cmd.id] = handler; + }; + + /** + * Add a command that can only be invoked on the inbox server + */ + const addInboxServerCommand = (trigger, parameters, handler, commandConfig = {}) => { + const aliases = aliasMap.has(trigger) ? [...aliasMap.get(trigger)] : []; + if (commandConfig.aliases) aliases.push(...commandConfig.aliases); + + const cmd = manager.add(trigger, parameters, { + ...commandConfig, + aliases, + preFilters: [ + (_, context) => { + if (! utils.messageIsOnInboxServer(context.msg)) return false; + if (! utils.isStaff(context.msg.member)) return false; + return true; + } + ] + }); + + handlers[cmd.id] = async (msg, args) => { + const thread = await threads.findOpenThreadByChannelId(msg.channel.id); + handler(msg, args, thread); + }; + }; + + /** + * Add a command that can only be invoked in a thread on the inbox server + */ + const addInboxThreadCommand = (trigger, parameters, handler, commandConfig = {}) => { + const aliases = aliasMap.has(trigger) ? [...aliasMap.get(trigger)] : []; + if (commandConfig.aliases) aliases.push(...commandConfig.aliases); + + let thread; + const cmd = manager.add(trigger, parameters, { + ...commandConfig, + aliases, + preFilters: [ + async (_, context) => { + if (! utils.messageIsOnInboxServer(context.msg)) return false; + if (! utils.isStaff(context.msg.member)) return false; + thread = await threads.findOpenThreadByChannelId(context.msg.channel.id); + if (! thread) return false; + return true; + } + ] + }); + + handlers[cmd.id] = async (msg, args) => { + handler(msg, args, thread); + }; + }; + + const addAlias = (originalCmd, alias) => { + if (! aliasMap.has(originalCmd)) { + aliasMap.set(originalCmd, new Set()); + } + + aliasMap.get(originalCmd).add(alias); + }; + + return { + manager, + addGlobalCommand, + addInboxServerCommand, + addInboxThreadCommand, + addAlias, + }; + } +}; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..a2e3beb --- /dev/null +++ b/src/config.js @@ -0,0 +1,289 @@ +/** + * !!! NOTE !!! + * + * If you're setting up the bot, DO NOT EDIT THIS FILE DIRECTLY! + * + * Create a configuration file in the same directory as the example file. + * You never need to edit anything under src/ to use the bot. + * + * !!! NOTE !!! + */ + +const fs = require('fs'); +const path = require('path'); + +let userConfig = {}; + +// Config files to search for, in priority order +const configFiles = [ + 'config.ini', + 'config.ini.ini', + 'config.ini.txt', + 'config.json', + 'config.json5', + 'config.json.json', + 'config.json.txt', + 'config.js' +]; + +let foundConfigFile; +for (const configFile of configFiles) { + try { + fs.accessSync(__dirname + '/../' + configFile); + foundConfigFile = configFile; + break; + } catch (e) {} +} + +// Load config file +if (foundConfigFile) { + console.log(`Loading configuration from ${foundConfigFile}...`); + try { + if (foundConfigFile.endsWith('.js')) { + userConfig = require(`../${foundConfigFile}`); + } else { + const raw = fs.readFileSync(__dirname + '/../' + foundConfigFile, {encoding: "utf8"}); + if (foundConfigFile.endsWith('.ini') || foundConfigFile.endsWith('.ini.txt')) { + userConfig = require('ini').decode(raw); + } else { + userConfig = require('json5').parse(raw); + } + } + } catch (e) { + throw new Error(`Error reading config file! The error given was: ${e.message}`); + } +} + +const required = ['token', 'mailGuildId', 'mainGuildId', 'logChannelId']; +const numericOptions = ['requiredAccountAge', 'requiredTimeOnServer', 'smallAttachmentLimit', 'port']; + +const defaultConfig = { + "token": null, + "mailGuildId": null, + "mainGuildId": null, + "logChannelId": null, + + "prefix": "!", + "snippetPrefix": "!!", + "snippetPrefixAnon": "!!!", + + "status": "Message me for help!", + "responseMessage": "Thank you for your message! Our mod team will reply to you here as soon as possible.", + "closeMessage": null, + "allowUserClose": false, + + "newThreadCategoryId": null, + "mentionRole": "here", + "pingOnBotMention": true, + "botMentionResponse": null, + + "inboxServerPermission": null, + "alwaysReply": false, + "alwaysReplyAnon": false, + "useNicknames": false, + "ignoreAccidentalThreads": false, + "threadTimestamps": false, + "allowMove": false, + "syncPermissionsOnMove": true, + "typingProxy": false, + "typingProxyReverse": false, + "mentionUserInThreadHeader": false, + "rolesInThreadHeader": false, + + "enableGreeting": false, + "greetingMessage": null, + "greetingAttachment": null, + + "guildGreetings": {}, + + "requiredAccountAge": null, // In hours + "accountAgeDeniedMessage": "Your Discord account is not old enough to contact modmail.", + + "requiredTimeOnServer": null, // In minutes + "timeOnServerDeniedMessage": "You haven't been a member of the server for long enough to contact modmail.", + + "relaySmallAttachmentsAsAttachments": false, + "smallAttachmentLimit": 1024 * 1024 * 2, + "attachmentStorage": "local", + "attachmentStorageChannelId": null, + + "categoryAutomation": {}, + + "updateNotifications": true, + "plugins": [], + + "commandAliases": {}, + + "port": 8890, + "url": null, + + "dbDir": path.join(__dirname, '..', 'db'), + "knex": null, + + "logDir": path.join(__dirname, '..', 'logs'), +}; + +// Load config values from environment variables +const envKeyPrefix = 'MM_'; +let loadedEnvValues = 0; + +for (const [key, value] of Object.entries(process.env)) { + if (! key.startsWith(envKeyPrefix)) continue; + + // MM_CLOSE_MESSAGE -> closeMessage + // MM_COMMAND_ALIASES__MV => commandAliases.mv + const configKey = key.slice(envKeyPrefix.length) + .toLowerCase() + .replace(/([a-z])_([a-z])/g, (m, m1, m2) => `${m1}${m2.toUpperCase()}`) + .replace('__', '.'); + + userConfig[configKey] = value.includes('||') + ? value.split('||') + : value; + + loadedEnvValues++; +} + +if (process.env.PORT && !process.env.MM_PORT) { + // Special case: allow common "PORT" environment variable without prefix + userConfig.port = process.env.PORT; + loadedEnvValues++; +} + +if (loadedEnvValues > 0) { + console.log(`Loaded ${loadedEnvValues} ${loadedEnvValues === 1 ? 'value' : 'values'} from environment variables`); +} + +// Convert config keys with periods to objects +// E.g. commandAliases.mv -> commandAliases: { mv: ... } +for (const [key, value] of Object.entries(userConfig)) { + if (! key.includes('.')) continue; + + const keys = key.split('.'); + let cursor = userConfig; + for (let i = 0; i < keys.length; i++) { + if (i === keys.length - 1) { + cursor[keys[i]] = value; + } else { + cursor[keys[i]] = cursor[keys[i]] || {}; + cursor = cursor[keys[i]]; + } + } + + delete userConfig[key]; +} + +// Combine user config with default config to form final config +const finalConfig = Object.assign({}, defaultConfig); + +for (const [prop, value] of Object.entries(userConfig)) { + if (! defaultConfig.hasOwnProperty(prop)) { + throw new Error(`Unknown option: ${prop}`); + } + + finalConfig[prop] = value; +} + +// Default knex config +if (! finalConfig['knex']) { + finalConfig['knex'] = { + client: 'sqlite', + connection: { + filename: path.join(finalConfig.dbDir, 'data.sqlite') + }, + useNullAsDefault: true + }; +} + +// Make sure migration settings are always present in knex config +Object.assign(finalConfig['knex'], { + migrations: { + directory: path.join(finalConfig.dbDir, 'migrations') + } +}); + +if (finalConfig.smallAttachmentLimit > 1024 * 1024 * 8) { + finalConfig.smallAttachmentLimit = 1024 * 1024 * 8; + console.warn('[WARN] smallAttachmentLimit capped at 8MB'); +} + +// Specific checks +if (finalConfig.attachmentStorage === 'discord' && ! finalConfig.attachmentStorageChannelId) { + console.error('Config option \'attachmentStorageChannelId\' is required with attachment storage \'discord\''); + process.exit(1); +} + +// Make sure mainGuildId is internally always an array +if (! Array.isArray(finalConfig['mainGuildId'])) { + finalConfig['mainGuildId'] = [finalConfig['mainGuildId']]; +} + +// Make sure inboxServerPermission is always an array +if (! Array.isArray(finalConfig['inboxServerPermission'])) { + if (finalConfig['inboxServerPermission'] == null) { + finalConfig['inboxServerPermission'] = []; + } else { + finalConfig['inboxServerPermission'] = [finalConfig['inboxServerPermission']]; + } +} + +// Move greetingMessage/greetingAttachment to the guildGreetings object internally +// Or, in other words, if greetingMessage and/or greetingAttachment is set, it is applied for all servers that don't +// already have something set up in guildGreetings. This retains backwards compatibility while allowing you to override +// greetings for specific servers in guildGreetings. +if (finalConfig.greetingMessage || finalConfig.greetingAttachment) { + for (const guildId of finalConfig.mainGuildId) { + if (finalConfig.guildGreetings[guildId]) continue; + finalConfig.guildGreetings[guildId] = { + message: finalConfig.greetingMessage, + attachment: finalConfig.greetingAttachment + }; + } +} + +// newThreadCategoryId is syntactic sugar for categoryAutomation.newThread +if (finalConfig.newThreadCategoryId) { + finalConfig.categoryAutomation.newThread = finalConfig.newThreadCategoryId; + delete finalConfig.newThreadCategoryId; +} + +// Turn empty string options to null (i.e. "option=" without a value in config.ini) +for (const [key, value] of Object.entries(finalConfig)) { + if (value === '') { + finalConfig[key] = null; + } +} + +// Cast numeric options to numbers +for (const numericOpt of numericOptions) { + if (finalConfig[numericOpt] != null) { + const number = parseFloat(finalConfig[numericOpt]); + if (Number.isNaN(number)) { + console.error(`Invalid numeric value for ${numericOpt}: ${finalConfig[numericOpt]}`); + process.exit(1); + } + finalConfig[numericOpt] = number; + } +} + +// Cast boolean options (on, true, 1) (off, false, 0) +for (const [key, value] of Object.entries(finalConfig)) { + if (typeof value !== "string") continue; + if (["on", "true", "1"].includes(value)) { + finalConfig[key] = true; + } else if (["off", "false", "0"].includes(value)) { + finalConfig[key] = false; + } +} + +// Make sure all of the required config options are present +for (const opt of required) { + if (! finalConfig[opt]) { + console.error(`Missing required config.json value: ${opt}`); + process.exit(1); + } +} + +console.log("Configuration ok!"); + +module.exports = finalConfig; 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 +}; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..dca80ac --- /dev/null +++ b/src/index.js @@ -0,0 +1,93 @@ +// Verify NodeJS version +const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); +if (nodeMajorVersion < 10) { + console.error('Unsupported NodeJS version! Please install NodeJS 10 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 starting the bot'); + process.exit(1); +} + +// Error handling +process.on('uncaughtException', err => { + // Unknown message types (nitro boosting messages at the time) should be safe to ignore + if (err && err.message && err.message.startsWith('Unhandled MESSAGE_CREATE type')) { + return; + } + + // For everything else, crash with the error + console.error(err); + process.exit(1); +}); + +let testedPackage = ''; +try { + const packageJson = require('../package.json'); + const modules = Object.keys(packageJson.dependencies); + modules.forEach(mod => { + testedPackage = mod; + fs.accessSync(path.join(__dirname, '..', 'node_modules', mod)) + }); +} catch (e) { + console.error(`Please run "npm install" again! Package "${testedPackage}" is missing.`); + process.exit(1); +} + +const config = require('./config'); +const utils = require('./utils'); +const main = require('./main'); +const knex = require('./knex'); +const legacyMigrator = require('./legacy/legacyMigrator'); + +// Force crash on unhandled rejections (use something like forever/pm2 to restart) +process.on('unhandledRejection', err => { + if (err instanceof utils.BotError || (err && err.code)) { + // We ignore stack traces for BotErrors (the message has enough info) and network errors from Eris (their stack traces are unreadably long) + console.error(`Error: ${err.message}`); + } else { + console.error(err); + } + + process.exit(1); +}); + +(async function() { + // Make sure the database is up to date + await knex.migrate.latest(); + + // Migrate legacy data if we need to + if (await legacyMigrator.shouldMigrate()) { + console.log('=== MIGRATING LEGACY DATA ==='); + console.log('Do not close the bot!'); + console.log(''); + + await legacyMigrator.migrate(); + + const relativeDbDir = (path.isAbsolute(config.dbDir) ? config.dbDir : path.resolve(process.cwd(), config.dbDir)); + const relativeLogDir = (path.isAbsolute(config.logDir) ? config.logDir : path.resolve(process.cwd(), config.logDir)); + + console.log(''); + 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, the following files/directories are no longer needed. I would recommend keeping a backup of them, however.'); + console.log(''); + console.log('FILE: ' + path.resolve(relativeDbDir, 'threads.json')); + console.log('FILE: ' + path.resolve(relativeDbDir, 'blocked.json')); + console.log('FILE: ' + path.resolve(relativeDbDir, 'snippets.json')); + console.log('DIRECTORY: ' + relativeLogDir); + console.log(''); + console.log('Starting the bot...'); + } + + // Start the bot + main.start(); +})(); diff --git a/src/knex.js b/src/knex.js new file mode 100644 index 0000000..b6b6346 --- /dev/null +++ b/src/knex.js @@ -0,0 +1,2 @@ +const config = require('./config'); +module.exports = require('knex')(config.knex); diff --git a/src/legacy/jsonDb.js b/src/legacy/jsonDb.js new file mode 100644 index 0000000..d2e8ca1 --- /dev/null +++ b/src/legacy/jsonDb.js @@ -0,0 +1,71 @@ +const fs = require('fs'); +const path = require('path'); +const config = require('../config'); + +const dbDir = config.dbDir; + +const databases = {}; + +/** + * @deprecated Only used for migrating legacy data + */ +class JSONDB { + constructor(path, def = {}, useCloneByDefault = false) { + this.path = path; + this.useCloneByDefault = useCloneByDefault; + + this.load = new Promise(resolve => { + fs.readFile(path, {encoding: 'utf8'}, (err, data) => { + if (err) return resolve(def); + + let unserialized; + try { unserialized = JSON.parse(data); } + catch (e) { unserialized = def; } + + resolve(unserialized); + }); + }); + } + + get(clone) { + if (clone == null) clone = this.useCloneByDefault; + + return this.load.then(data => { + if (clone) return JSON.parse(JSON.stringify(data)); + else return data; + }); + } + + save(newData) { + const serialized = JSON.stringify(newData); + this.load = new Promise((resolve, reject) => { + fs.writeFile(this.path, serialized, {encoding: 'utf8'}, () => { + resolve(newData); + }); + }); + + return this.get(); + } +} + +function getDb(dbName, def) { + if (! databases[dbName]) { + const dbPath = path.resolve(dbDir, `${dbName}.json`); + databases[dbName] = new JSONDB(dbPath, def); + } + + return databases[dbName]; +} + +function get(dbName, def) { + return getDb(dbName, def).get(); +} + +function save(dbName, data) { + return getDb(dbName, data).save(data); +} + +module.exports = { + get, + save, +}; diff --git a/src/legacy/legacyMigrator.js b/src/legacy/legacyMigrator.js new file mode 100644 index 0000000..2c491e3 --- /dev/null +++ b/src/legacy/legacyMigrator.js @@ -0,0 +1,222 @@ +const fs = require('fs'); +const path = require('path'); +const promisify = require('util').promisify; +const moment = require('moment'); +const Eris = require('eris'); + +const knex = require('../knex'); +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); +const writeFile = promisify(fs.writeFile); + +async function migrate() { + console.log('Migrating open threads...'); + await migrateOpenThreads(); + + console.log('Migrating logs...'); + await migrateLogs(); + + console.log('Migrating blocked users...'); + await migrateBlockedUsers(); + + console.log('Migrating snippets...'); + await migrateSnippets(); + + await writeFile(path.join(config.dbDir, '.migrated_legacy'), ''); +} + +async function shouldMigrate() { + // If there is a file marking a finished migration, assume we don't need to migrate + const migrationFile = path.join(config.dbDir, '.migrated_legacy'); + try { + await access(migrationFile); + return false; + } catch (e) {} + + // If there are any old threads, we need to migrate + const oldThreads = await jsonDb.get('threads', []); + if (oldThreads.length) { + return true; + } + + // If there are any old blocked users, we need to migrate + const blockedUsers = await jsonDb.get('blocked', []); + if (blockedUsers.length) { + return true; + } + + // If there are any old snippets, we need to migrate + const snippets = await jsonDb.get('snippets', {}); + if (Object.keys(snippets).length) { + return true; + } + + // If the log file dir exists and has logs in it, we need to migrate + try { + const files = await readDir(config.logDir); + if (files.length > 1) return true; // > 1, since .gitignore is one of them + } catch(e) {} + + return false; +} + +async function migrateOpenThreads() { + const bot = new Eris.Client(config.token); + + const toReturn = new Promise(resolve => { + bot.on('ready', async () => { + const oldThreads = await jsonDb.get('threads', []); + + const promises = oldThreads.map(async oldThread => { + const existingOpenThread = await knex('threads') + .where('channel_id', oldThread.channelId) + .first(); + + if (existingOpenThread) return; + + const oldChannel = bot.getChannel(oldThread.channelId); + if (! oldChannel) return; + + const threadMessages = await oldChannel.getMessages(1000); + const log = threadMessages.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 newThread = { + status: THREAD_STATUS.OPEN, + user_id: oldThread.userId, + user_name: oldThread.username, + channel_id: oldThread.channelId, + is_legacy: 1 + }; + + const threadId = await threads.createThreadInDB(newThread); + + await knex('thread_messages').insert({ + thread_id: threadId, + message_type: THREAD_MESSAGE_TYPE.LEGACY, + user_id: oldThread.userId, + user_name: '', + body: log, + is_anonymous: 0, + created_at: moment.utc().format('YYYY-MM-DD HH:mm:ss') + }); + }); + + resolve(Promise.all(promises)); + }); + + bot.connect(); + }); + + await toReturn; + + bot.disconnect(); +} + +async function migrateLogs() { + const logDir = config.logDir || `${__dirname}/../../logs`; + const logFiles = await readDir(logDir); + + for (let i = 0; i < logFiles.length; i++) { + const logFile = logFiles[i]; + if (! logFile.endsWith('.txt')) continue; + + const [rawDate, userId, threadId] = logFile.slice(0, -4).split('__'); + const date = `${rawDate.slice(0, 10)} ${rawDate.slice(11).replace('-', ':')}`; + + const fullPath = path.join(logDir, logFile); + const contents = await readFile(fullPath, {encoding: 'utf8'}); + + const newThread = { + id: threadId, + status: THREAD_STATUS.CLOSED, + user_id: userId, + user_name: '', + channel_id: null, + is_legacy: 1, + created_at: date + }; + + await knex.transaction(async trx => { + const existingThread = await trx('threads') + .where('id', newThread.id) + .first(); + + if (existingThread) return; + + await trx('threads').insert(newThread); + + await trx('thread_messages').insert({ + thread_id: newThread.id, + message_type: THREAD_MESSAGE_TYPE.LEGACY, + user_id: userId, + user_name: '', + body: contents, + is_anonymous: 0, + created_at: date + }); + }); + + // Progress indicator for servers with tons of logs + if ((i + 1) % 500 === 0) { + console.log(` ${i + 1}...`); + } + } +} + +async function migrateBlockedUsers() { + const now = moment.utc().format('YYYY-MM-DD HH:mm:ss'); + const blockedUsers = await jsonDb.get('blocked', []); + + for (const userId of blockedUsers) { + const existingBlockedUser = await knex('blocked_users') + .where('user_id', userId) + .first(); + + if (existingBlockedUser) return; + + await knex('blocked_users').insert({ + user_id: userId, + user_name: '', + blocked_by: null, + blocked_at: now + }); + } +} + +async function migrateSnippets() { + const now = moment.utc().format('YYYY-MM-DD HH:mm:ss'); + const snippets = await jsonDb.get('snippets', {}); + + const promises = Object.entries(snippets).map(async ([trigger, data]) => { + const existingSnippet = await knex('snippets') + .where('trigger', trigger) + .first(); + + if (existingSnippet) return; + + return knex('snippets').insert({ + trigger, + body: data.text, + is_anonymous: data.isAnonymous ? 1 : 0, + created_by: null, + created_at: now + }); + }); + + return Promise.all(promises); +} + +module.exports = { + migrate, + shouldMigrate, +}; diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..b8ef515 --- /dev/null +++ b/src/main.js @@ -0,0 +1,281 @@ +const Eris = require('eris'); +const path = require('path'); + +const config = require('./config'); +const bot = require('./bot'); +const knex = require('./knex'); +const {messageQueue} = require('./queue'); +const utils = require('./utils'); +const { createCommandManager } = require('./commands'); +const { getPluginAPI, loadPlugin } = require('./plugins'); + +const blocked = require('./data/blocked'); +const threads = require('./data/threads'); +const updates = require('./data/updates'); + +const reply = require('./modules/reply'); +const close = require('./modules/close'); +const snippets = require('./modules/snippets'); +const logs = require('./modules/logs'); +const move = require('./modules/move'); +const block = require('./modules/block'); +const suspend = require('./modules/suspend'); +const webserver = require('./modules/webserver'); +const greeting = require('./modules/greeting'); +const typingProxy = require('./modules/typingProxy'); +const version = require('./modules/version'); +const newthread = require('./modules/newthread'); +const idModule = require('./modules/id'); +const alert = require('./modules/alert'); + +const {ACCIDENTAL_THREAD_MESSAGES} = require('./data/constants'); + +module.exports = { + async start() { + console.log('Connecting to Discord...'); + + bot.once('ready', async () => { + console.log('Connected! Waiting for guilds to become available...'); + await Promise.all([ + ...config.mainGuildId.map(id => waitForGuild(id)), + waitForGuild(config.mailGuildId) + ]); + + console.log('Initializing...'); + initStatus(); + initBaseMessageHandlers(); + initPlugins(); + + console.log(''); + console.log('Done! Now listening to DMs.'); + console.log(''); + }); + + bot.connect(); + } +}; + +function waitForGuild(guildId) { + if (bot.guilds.has(guildId)) { + return Promise.resolve(); + } + + return new Promise(resolve => { + bot.on('guildAvailable', guild => { + if (guild.id === guildId) { + resolve(); + } + }); + }); +} + +function initStatus() { + function applyStatus() { + bot.editStatus(null, {name: config.status}); + } + + // Set the bot status initially, then reapply it every hour since in some cases it gets unset + applyStatus(); + setInterval(applyStatus, 60 * 60 * 1000); +} + +function initBaseMessageHandlers() { + /** + * 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 (msg.author.bot) return; + + const thread = await threads.findByChannelId(msg.channel.id); + if (! thread) return; + + if (msg.content.startsWith(config.prefix) || msg.content.startsWith(config.snippetPrefix)) { + // Save commands as "command messages" + if (msg.content.startsWith(config.snippetPrefix)) return; // Ignore snippets + thread.saveCommandMessage(msg); + } else if (config.alwaysReply) { + // AUTO-REPLY: If config.alwaysReply is enabled, send all chat messages in thread channels as replies + if (! utils.isStaff(msg.member)) return; // Only staff are allowed to reply + + const replied = await thread.replyToUser(msg.member, msg.content.trim(), msg.attachments, config.alwaysReplyAnon || false); + if (replied) msg.delete(); + } else { + // Otherwise just save the messages as "chat" in the logs + thread.saveChatMessage(msg); + } + }); + + /** + * 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 () => { + let thread = await threads.findOpenThreadByUserId(msg.author.id); + + + // New thread + if (! thread) { + // Ignore messages that shouldn't usually open new threads, such as "ok", "thanks", etc. + if (config.ignoreAccidentalThreads && msg.content && ACCIDENTAL_THREAD_MESSAGES.includes(msg.content.trim().toLowerCase())) return; + + thread = await threads.createNewThreadForUser(msg.author); + } + + if (thread) 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 || ! msg.author) return; + if (msg.author.bot) return; + if (await blocked.isBlocked(msg.author.id)) return; + + // Old message content doesn't persist between bot restarts + const oldContent = oldMessage && oldMessage.content || '*Unavailable due to bot restart*'; + const newContent = msg.content; + + // 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); + if (! thread) return; + + 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) return; + 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; + if (msg.author.bot) return; + + if (utils.messageIsOnInboxServer(msg)) { + // For same server setups, check if the person who pinged modmail is staff. If so, ignore the ping. + if (utils.isStaff(msg.member)) return; + } else { + // For separate server setups, check if the member is staff on the modmail server + const inboxMember = utils.getInboxGuild().members.get(msg.author.id); + if (inboxMember && utils.isStaff(inboxMember)) return; + } + + // If the person who mentioned the bot is blocked, ignore them + if (await blocked.isBlocked(msg.author.id)) return; + + let content; + const mainGuilds = utils.getMainGuilds(); + const staffMention = (config.pingOnBotMention ? utils.getInboxMention() : ''); + + if (mainGuilds.length === 1) { + content = `${staffMention}Bot mentioned in ${msg.channel.mention} by **${msg.author.username}#${msg.author.discriminator}(${msg.author.id})**: "${msg.cleanContent}"\n\n<https:\/\/discordapp.com\/channels\/${msg.channel.guild.id}\/${msg.channel.id}\/${msg.id}>`; + } else { + content = `${staffMention}Bot mentioned in ${msg.channel.mention} (${msg.channel.guild.name}) by **${msg.author.username}#${msg.author.discriminator}(${msg.author.id})**: "${msg.cleanContent}"\n\n<https:\/\/discordapp.com\/channels\/${msg.channel.guild.id}\/${msg.channel.id}\/${msg.id}>`; + } + + + bot.createMessage(utils.getLogChannel().id, { + content, + disableEveryone: false, + }); + + // Send an auto-response to the mention, if enabled + if (config.botMentionResponse) { + const botMentionResponse = utils.readMultilineConfigValue(config.botMentionResponse); + bot.createMessage(msg.channel.id, botMentionResponse.replace(/{userMention}/g, `<@${msg.author.id}>`)); + } + }); +} + +function initPlugins() { + // Initialize command manager + const commands = createCommandManager(bot); + + // Register command aliases + if (config.commandAliases) { + for (const alias in config.commandAliases) { + commands.addAlias(config.commandAliases[alias], alias); + } + } + + // Load plugins + console.log('Loading plugins'); + const builtInPlugins = [ + reply, + close, + logs, + block, + move, + snippets, + suspend, + greeting, + webserver, + typingProxy, + version, + newthread, + idModule, + alert + ]; + + const plugins = [...builtInPlugins]; + + if (config.plugins && config.plugins.length) { + for (const plugin of config.plugins) { + const pluginFn = require(`../${plugin}`); + plugins.push(pluginFn); + } + } + + const pluginApi = getPluginAPI({ bot, knex, config, commands }); + plugins.forEach(pluginFn => loadPlugin(pluginFn, pluginApi)); + + console.log(`Loaded ${plugins.length} plugins (${builtInPlugins.length} built-in plugins, ${plugins.length - builtInPlugins.length} external plugins)`); + + if (config.updateNotifications) { + updates.startVersionRefreshLoop(); + } +} diff --git a/src/modules/alert.js b/src/modules/alert.js new file mode 100644 index 0000000..a844230 --- /dev/null +++ b/src/modules/alert.js @@ -0,0 +1,11 @@ +module.exports = ({ bot, knex, config, commands }) => { + commands.addInboxThreadCommand('alert', '[opt:string]', async (msg, args, thread) => { + if (args.opt && args.opt.startsWith('c')) { + await thread.setAlert(null); + await thread.postSystemMessage(`Cancelled new message alert`); + } else { + await thread.setAlert(msg.author.id); + await thread.postSystemMessage(`Pinging ${msg.author.username}#${msg.author.discriminator} when this thread gets a new reply`); + } + }); +}; diff --git a/src/modules/block.js b/src/modules/block.js new file mode 100644 index 0000000..94913c4 --- /dev/null +++ b/src/modules/block.js @@ -0,0 +1,99 @@ +const humanizeDuration = require('humanize-duration'); +const moment = require('moment'); +const blocked = require("../data/blocked"); +const utils = require("../utils"); + +module.exports = ({ bot, knex, config, commands }) => { + async function removeExpiredBlocks() { + const expiredBlocks = await blocked.getExpiredBlocks(); + const logChannel = utils.getLogChannel(); + for (const userId of expiredBlocks) { + await blocked.unblock(userId); + logChannel.createMessage(`Block of <@!${userId}> (id \`${userId}\`) expired`); + } + } + + async function expiredBlockLoop() { + try { + removeExpiredBlocks(); + } catch (e) { + console.error(e); + } + + setTimeout(expiredBlockLoop, 2000); + } + + bot.on('ready', expiredBlockLoop); + + const blockCmd = async (msg, args, thread) => { + const userIdToBlock = args.userId || (thread && thread.user_id); + if (! userIdToBlock) return; + + const isBlocked = await blocked.isBlocked(userIdToBlock); + if (isBlocked) { + msg.channel.createMessage('User is already blocked'); + return; + } + + const expiresAt = args.blockTime + ? moment.utc().add(args.blockTime, 'ms').format('YYYY-MM-DD HH:mm:ss') + : null; + + const user = bot.users.get(userIdToBlock); + await blocked.block(userIdToBlock, (user ? `${user.username}#${user.discriminator}` : ''), msg.author.id, expiresAt); + + if (expiresAt) { + const humanized = humanizeDuration(args.blockTime, { largest: 2, round: true }); + msg.channel.createMessage(`Blocked <@${userIdToBlock}> (id \`${userIdToBlock}\`) from modmail for ${humanized}`); + } else { + msg.channel.createMessage(`Blocked <@${userIdToBlock}> (id \`${userIdToBlock}\`) from modmail indefinitely`); + } + }; + + commands.addInboxServerCommand('block', '<userId:userId> [blockTime:delay]', blockCmd); + commands.addInboxServerCommand('block', '[blockTime:delay]', blockCmd); + + const unblockCmd = async (msg, args, thread) => { + const userIdToUnblock = args.userId || (thread && thread.user_id); + if (! userIdToUnblock) return; + + const isBlocked = await blocked.isBlocked(userIdToUnblock); + if (! isBlocked) { + msg.channel.createMessage('User is not blocked'); + return; + } + + const unblockAt = args.unblockDelay + ? moment.utc().add(args.unblockDelay, 'ms').format('YYYY-MM-DD HH:mm:ss') + : null; + + const user = bot.users.get(userIdToUnblock); + if (unblockAt) { + const humanized = humanizeDuration(args.unblockDelay, { largest: 2, round: true }); + await blocked.updateExpiryTime(userIdToUnblock, unblockAt); + msg.channel.createMessage(`Scheduled <@${userIdToUnblock}> (id \`${userIdToUnblock}\`) to be unblocked in ${humanized}`); + } else { + await blocked.unblock(userIdToUnblock); + msg.channel.createMessage(`Unblocked <@${userIdToUnblock}> (id ${userIdToUnblock}) from modmail`); + } + }; + + commands.addInboxServerCommand('unblock', '<userId:userId> [unblockDelay:delay]', unblockCmd); + commands.addInboxServerCommand('unblock', '[unblockDelay:delay]', unblockCmd); + + commands.addInboxServerCommand('is_blocked', '[userId:userId]',async (msg, args, thread) => { + const userIdToCheck = args.userId || (thread && thread.user_id); + if (! userIdToCheck) return; + + const blockStatus = await blocked.getBlockStatus(userIdToCheck); + if (blockStatus.isBlocked) { + if (blockStatus.expiresAt) { + msg.channel.createMessage(`<@!${userIdToCheck}> (id \`${userIdToCheck}\`) is blocked until ${blockStatus.expiresAt} (UTC)`); + } else { + msg.channel.createMessage(`<@!${userIdToCheck}> (id \`${userIdToCheck}\`) is blocked indefinitely`); + } + } else { + msg.channel.createMessage(`<@!${userIdToCheck}> (id \`${userIdToCheck}\`) is NOT blocked`); + } + }); +}; diff --git a/src/modules/close.js b/src/modules/close.js new file mode 100644 index 0000000..b3e7421 --- /dev/null +++ b/src/modules/close.js @@ -0,0 +1,153 @@ +const moment = require('moment'); +const Eris = require('eris'); +const config = require('../config'); +const utils = require('../utils'); +const threads = require('../data/threads'); +const blocked = require('../data/blocked'); +const {messageQueue} = require('../queue'); + +module.exports = ({ bot, knex, config, commands }) => { + // Check for threads that are scheduled to be closed and close them + async function applyScheduledCloses() { + const threadsToBeClosed = await threads.getThreadsThatShouldBeClosed(); + for (const thread of threadsToBeClosed) { + if (config.closeMessage && ! thread.scheduled_close_silent) { + const closeMessage = utils.readMultilineConfigValue(config.closeMessage); + await thread.postToUser(closeMessage).catch(() => {}); + } + + await thread.close(false, thread.scheduled_close_silent); + + const logUrl = await thread.getLogUrl(); + utils.postLog(utils.trimAll(` + Modmail thread with ${thread.user_name} (${thread.user_id}) was closed as scheduled by ${thread.scheduled_close_name} + Logs: ${logUrl} + `)); + } + } + + async function scheduledCloseLoop() { + try { + await applyScheduledCloses(); + } catch (e) { + console.error(e); + } + + setTimeout(scheduledCloseLoop, 2000); + } + + scheduledCloseLoop(); + + // Close a thread. Closing a thread saves a log of the channel's contents and then deletes the channel. + commands.addGlobalCommand('close', '[opts...]', async (msg, args) => { + let thread, closedBy; + + let hasCloseMessage = !! config.closeMessage; + let silentClose = false; + + if (msg.channel instanceof Eris.PrivateChannel) { + // User is closing the thread by themselves (if enabled) + if (! config.allowUserClose) return; + if (await blocked.isBlocked(msg.author.id)) return; + + thread = await threads.findOpenThreadByUserId(msg.author.id); + if (! thread) return; + + // We need to add this operation to the message queue so we don't get a race condition + // between showing the close command in the thread and closing the thread + await messageQueue.add(async () => { + thread.postSystemMessage('Thread closed by user, closing...'); + await thread.close(true); + }); + + closedBy = 'the user'; + } else { + // A staff member is closing the thread + if (! utils.messageIsOnInboxServer(msg)) return; + if (! utils.isStaff(msg.member)) return; + + thread = await threads.findOpenThreadByChannelId(msg.channel.id); + if (! thread) return; + + if (args.opts && args.opts.length) { + if (args.opts.includes('cancel') || args.opts.includes('c')) { + // Cancel timed close + if (thread.scheduled_close_at) { + await thread.cancelScheduledClose(); + thread.postSystemMessage(`Cancelled scheduled closing`); + } + + return; + } + + // Silent close (= no close message) + if (args.opts.includes('silent') || args.opts.includes('s')) { + silentClose = true; + } + + // Timed close + const delayStringArg = args.opts.find(arg => utils.delayStringRegex.test(arg)); + if (delayStringArg) { + const delay = utils.convertDelayStringToMS(delayStringArg); + if (delay === 0 || delay === null) { + thread.postSystemMessage(`Invalid delay specified. Format: "1h30m"`); + return; + } + + const closeAt = moment.utc().add(delay, 'ms'); + await thread.scheduleClose(closeAt.format('YYYY-MM-DD HH:mm:ss'), msg.author, silentClose ? 1 : 0); + + let response; + if (silentClose) { + response = `Thread is now scheduled to be closed silently in ${utils.humanizeDelay(delay)}. Use \`${config.prefix}close cancel\` to cancel.`; + } else { + response = `Thread is now scheduled to be closed in ${utils.humanizeDelay(delay)}. Use \`${config.prefix}close cancel\` to cancel.`; + } + + thread.postSystemMessage(response); + + return; + } + } + + // Regular close + await thread.close(false, silentClose); + closedBy = msg.author.username; + } + + // Send close message (unless suppressed with a silent close) + if (hasCloseMessage && ! silentClose) { + const closeMessage = utils.readMultilineConfigValue(config.closeMessage); + await thread.postToUser(closeMessage).catch(() => {}); + } + + const logUrl = await thread.getLogUrl(); + utils.postLog(utils.trimAll(` + Modmail thread with ${thread.user_name} (${thread.user_id}) was closed by ${closedBy} + Logs: ${logUrl} + `)); + }); + + // Auto-close threads if their channel is deleted + bot.on('channelDelete', async (channel) => { + if (! (channel instanceof Eris.TextChannel)) return; + if (channel.guild.id !== utils.getInboxGuild().id) return; + + const thread = await threads.findOpenThreadByChannelId(channel.id); + if (! thread) return; + + console.log(`[INFO] Auto-closing thread with ${thread.user_name} because the channel was deleted`); + if (config.closeMessage) { + const closeMessage = utils.readMultilineConfigValue(config.closeMessage); + await thread.postToUser(closeMessage).catch(() => {}); + } + + await thread.close(true); + + const logUrl = await thread.getLogUrl(); + utils.postLog(utils.trimAll(` + Modmail thread with ${thread.user_name} (${thread.user_id}) was closed automatically because the channel was deleted + Logs: ${logUrl} + `)); + }); +}; diff --git a/src/modules/greeting.js b/src/modules/greeting.js new file mode 100644 index 0000000..f85bde9 --- /dev/null +++ b/src/modules/greeting.js @@ -0,0 +1,37 @@ +const path = require('path'); +const fs = require('fs'); +const config = require('../config'); +const utils = require('../utils'); + +module.exports = ({ bot }) => { + if (! config.enableGreeting) return; + + bot.on('guildMemberAdd', (guild, member) => { + const guildGreeting = config.guildGreetings[guild.id]; + if (! guildGreeting || (! guildGreeting.message && ! guildGreeting.attachment)) return; + + function sendGreeting(message, file) { + bot.getDMChannel(member.id).then(channel => { + if (! channel) return; + + channel.createMessage(message || '', file) + .catch(e => { + if (e.code === 50007) return; + throw e; + }); + }); + } + + const greetingMessage = utils.readMultilineConfigValue(guildGreeting.message); + + if (guildGreeting.attachment) { + const filename = path.basename(guildGreeting.attachment); + fs.readFile(guildGreeting.attachment, (err, data) => { + const file = {file: data, name: filename}; + sendGreeting(greetingMessage, file); + }); + } else { + sendGreeting(greetingMessage); + } + }); +}; diff --git a/src/modules/id.js b/src/modules/id.js new file mode 100644 index 0000000..e3cf2e2 --- /dev/null +++ b/src/modules/id.js @@ -0,0 +1,5 @@ +module.exports = ({ bot, knex, config, commands }) => { + commands.addInboxThreadCommand('id', [], async (msg, args, thread) => { + thread.postSystemMessage(thread.user_id); + }); +}; diff --git a/src/modules/logs.js b/src/modules/logs.js new file mode 100644 index 0000000..0664c07 --- /dev/null +++ b/src/modules/logs.js @@ -0,0 +1,69 @@ +const threads = require("../data/threads"); +const moment = require('moment'); +const utils = require("../utils"); + +const LOG_LINES_PER_PAGE = 10; + +module.exports = ({ bot, knex, config, commands }) => { + const logsCmd = async (msg, args, thread) => { + let userId = args.userId || (thread && thread.user_id); + if (! userId) return; + + let userThreads = await threads.getClosedThreadsByUserId(userId); + + // Descending by date + userThreads.sort((a, b) => { + if (a.created_at > b.created_at) return -1; + if (a.created_at < b.created_at) return 1; + return 0; + }); + + // Pagination + const totalUserThreads = userThreads.length; + const maxPage = Math.ceil(totalUserThreads / LOG_LINES_PER_PAGE); + const inputPage = args.page; + const page = Math.max(Math.min(inputPage ? parseInt(inputPage, 10) : 1, maxPage), 1); // Clamp page to 1-<max page> + const isPaginated = totalUserThreads > LOG_LINES_PER_PAGE; + const start = (page - 1) * LOG_LINES_PER_PAGE; + const end = page * LOG_LINES_PER_PAGE; + userThreads = userThreads.slice((page - 1) * LOG_LINES_PER_PAGE, page * LOG_LINES_PER_PAGE); + + 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}>`; + })); + + let message = isPaginated + ? `**Log files for <@${userId}>** (page **${page}/${maxPage}**, showing logs **${start + 1}-${end}/${totalUserThreads}**):` + : `**Log files for <@${userId}>:**`; + + message += `\n${threadLines.join('\n')}`; + + if (isPaginated) { + message += `\nTo view more, add a page number to the end of the command`; + } + + // Send the list of logs in chunks of 15 lines per message + const lines = message.split('\n'); + const chunks = utils.chunk(lines, 15); + + let root = Promise.resolve(); + chunks.forEach(lines => { + root = root.then(() => msg.channel.createMessage(lines.join('\n'))); + }); + }; + + commands.addInboxServerCommand('logs', '<userId:userId> [page:number]', logsCmd); + commands.addInboxServerCommand('logs', '[page:number]', logsCmd); + + commands.addInboxServerCommand('loglink', [], async (msg, args, thread) => { + if (! thread) { + thread = await threads.findSuspendedThreadByChannelId(msg.channel.id); + if (! thread) return; + } + + const logUrl = await thread.getLogUrl(); + thread.postSystemMessage(`Log URL: ${logUrl}`); + }); +}; diff --git a/src/modules/move.js b/src/modules/move.js new file mode 100644 index 0000000..973143d --- /dev/null +++ b/src/modules/move.js @@ -0,0 +1,82 @@ +const config = require('../config'); +const Eris = require('eris'); +const transliterate = require("transliteration"); +const erisEndpoints = require('eris/lib/rest/Endpoints'); + +module.exports = ({ bot, knex, config, commands }) => { + if (! config.allowMove) return; + + commands.addInboxThreadCommand('move', '<category:string$>', async (msg, args, thread) => { + const searchStr = args.category; + const normalizedSearchStr = transliterate.slugify(searchStr); + + const categories = msg.channel.guild.channels.filter(c => { + // Filter to categories that are not the thread's current parent category + return (c instanceof Eris.CategoryChannel) && (c.id !== msg.channel.parentID); + }); + + if (categories.length === 0) return; + + // See if any category name contains a part of the search string + const containsRankings = categories.map(cat => { + const normalizedCatName = transliterate.slugify(cat.name); + + let i = 0; + do { + if (! normalizedCatName.includes(normalizedSearchStr.slice(0, i + 1))) break; + i++; + } while (i < normalizedSearchStr.length); + + if (i > 0 && normalizedCatName.startsWith(normalizedSearchStr.slice(0, i))) { + // Slightly prioritize categories that *start* with the search string + i += 0.5; + } + + return [cat, i]; + }); + + // Sort by best match + containsRankings.sort((a, b) => { + return a[1] > b[1] ? -1 : 1; + }); + + if (containsRankings[0][1] === 0) { + thread.postSystemMessage('No matching category'); + return; + } + + const targetCategory = containsRankings[0][0]; + + try { + await bot.editChannel(thread.channel_id, { + parentID: targetCategory.id + }); + } catch (e) { + thread.postSystemMessage(`Failed to move thread: ${e.message}`); + return; + } + + // If enabled, sync thread channel permissions with the category it's moved to + if (config.syncPermissionsOnMove) { + const newPerms = Array.from(targetCategory.permissionOverwrites.map(ow => { + return { + id: ow.id, + type: ow.type, + allow: ow.allow, + deny: ow.deny + }; + })); + + try { + await bot.requestHandler.request("PATCH", erisEndpoints.CHANNEL(thread.channel_id), true, { + permission_overwrites: newPerms + }); + } catch (e) { + thread.postSystemMessage(`Thread moved to ${targetCategory.name.toUpperCase()}, but failed to sync permissions: ${e.message}`); + return; + } + } + + thread.postSystemMessage(`Thread moved to ${targetCategory.name.toUpperCase()}`); + }); +}; diff --git a/src/modules/newthread.js b/src/modules/newthread.js new file mode 100644 index 0000000..aca6f54 --- /dev/null +++ b/src/modules/newthread.js @@ -0,0 +1,23 @@ +const utils = require("../utils"); +const threads = require("../data/threads"); + +module.exports = ({ bot, knex, config, commands }) => { + commands.addInboxServerCommand('newthread', '<userId:userId>', async (msg, args, thread) => { + const user = bot.users.get(args.userId); + if (! user) { + utils.postSystemMessageWithFallback(msg.channel, thread, 'User not found!'); + return; + } + + const existingThread = await threads.findOpenThreadByUserId(user.id); + if (existingThread) { + utils.postSystemMessageWithFallback(msg.channel, thread, `Cannot create a new thread; there is another open thread with this user: <#${existingThread.channel_id}>`); + return; + } + + const createdThread = await threads.createNewThreadForUser(user, true, true); + createdThread.postSystemMessage(`Thread was opened by ${msg.author.username}#${msg.author.discriminator}`); + + msg.channel.createMessage(`Thread opened: <#${createdThread.channel_id}>`); + }); +}; diff --git a/src/modules/reply.js b/src/modules/reply.js new file mode 100644 index 0000000..bc2afe3 --- /dev/null +++ b/src/modules/reply.js @@ -0,0 +1,32 @@ +const attachments = require("../data/attachments"); +const utils = require('../utils'); + +module.exports = ({ bot, knex, config, commands }) => { + // 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 + commands.addInboxThreadCommand('reply', '[text$]', async (msg, args, thread) => { + if (! args.text && msg.attachments.length === 0) { + utils.postError(msg.channel, 'Text or attachment required'); + return; + } + + const replied = await thread.replyToUser(msg.member, args.text || '', msg.attachments, false); + if (replied) msg.delete(); + }, { + aliases: ['r'] + }); + + + // Anonymous replies only show the role, not the username + commands.addInboxThreadCommand('anonreply', '[text$]', async (msg, args, thread) => { + if (! args.text && msg.attachments.length === 0) { + utils.postError(msg.channel, 'Text or attachment required'); + return; + } + + const replied = await thread.replyToUser(msg.member, args.text || '', msg.attachments, true); + if (replied) msg.delete(); + }, { + aliases: ['ar'] + }); +}; diff --git a/src/modules/snippets.js b/src/modules/snippets.js new file mode 100644 index 0000000..e9c1271 --- /dev/null +++ b/src/modules/snippets.js @@ -0,0 +1,138 @@ +const threads = require('../data/threads'); +const snippets = require('../data/snippets'); +const config = require('../config'); +const utils = require('../utils'); +const { parseArguments } = require('knub-command-manager'); + +const whitespaceRegex = /\s/; +const quoteChars = ["'", '"']; + +module.exports = ({ bot, knex, config, commands }) => { + /** + * "Renders" a snippet by replacing all argument placeholders e.g. {1} {2} with their corresponding arguments. + * The number in the placeholder is the argument's order in the argument list, i.e. {1} is the first argument (= index 0) + * @param {String} body + * @param {String[]} args + * @returns {String} + */ + function renderSnippet(body, args) { + return body + .replace(/(?<!\\){\d+}/g, match => { + const index = parseInt(match.slice(1, -1), 10) - 1; + return (args[index] != null ? args[index] : match); + }) + .replace(/\\{/g, '{'); + } + + /** + * 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) && ! msg.content.startsWith(config.snippetPrefixAnon)) return; + + let snippetPrefix, isAnonymous; + + if (config.snippetPrefixAnon.length > config.snippetPrefix.length) { + // Anonymous prefix is longer -> check it first + if (msg.content.startsWith(config.snippetPrefixAnon)) { + snippetPrefix = config.snippetPrefixAnon; + isAnonymous = true; + } else { + snippetPrefix = config.snippetPrefix; + isAnonymous = false; + } + } else { + // Regular prefix is longer -> check it first + if (msg.content.startsWith(config.snippetPrefix)) { + snippetPrefix = config.snippetPrefix; + isAnonymous = false; + } else { + snippetPrefix = config.snippetPrefixAnon; + isAnonymous = true; + } + } + + const thread = await threads.findByChannelId(msg.channel.id); + if (! thread) return; + + let [, trigger, rawArgs] = msg.content.slice(snippetPrefix.length).match(/(\S+)(?:\s+(.*))?/s); + trigger = trigger.toLowerCase(); + + const snippet = await snippets.get(trigger); + if (! snippet) return; + + let args = rawArgs ? parseArguments(rawArgs) : []; + args = args.map(arg => arg.value); + const rendered = renderSnippet(snippet.body, args); + + const replied = await thread.replyToUser(msg.member, rendered, [], isAnonymous); + if (replied) msg.delete(); + }); + + // Show or add a snippet + commands.addInboxServerCommand('snippet', '<trigger> [text$]', async (msg, args, thread) => { + const snippet = await snippets.get(args.trigger); + + if (snippet) { + if (args.text) { + // If the snippet exists and we're trying to create a new one, inform the user the snippet already exists + utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.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 + utils.postSystemMessageWithFallback(msg.channel, thread, `\`${config.snippetPrefix}${args.trigger}\` replies with: \`\`\`${utils.disableCodeBlocks(snippet.body)}\`\`\``); + } + } else { + if (args.text) { + // If the snippet doesn't exist and the user wants to create it, create it + await snippets.add(args.trigger, args.text, msg.author.id); + utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" created!`); + } else { + // If the snippet doesn't exist and the user isn't trying to create it, inform them how to create it + utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" doesn't exist! You can create it with \`${config.prefix}snippet ${args.trigger} text\``); + } + } + }, { + aliases: ['s'] + }); + + commands.addInboxServerCommand('delete_snippet', '<trigger>', async (msg, args, thread) => { + const snippet = await snippets.get(args.trigger); + if (! snippet) { + utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" doesn't exist!`); + return; + } + + await snippets.del(args.trigger); + utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" deleted!`); + }, { + aliases: ['ds'] + }); + + commands.addInboxServerCommand('edit_snippet', '<trigger> [text$]', async (msg, args, thread) => { + const snippet = await snippets.get(args.trigger); + if (! snippet) { + utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" doesn't exist!`); + return; + } + + await snippets.del(args.trigger); + await snippets.add(args.trigger, args.text, msg.author.id); + + utils.postSystemMessageWithFallback(msg.channel, thread, `Snippet "${args.trigger}" edited!`); + }, { + aliases: ['es'] + }); + + commands.addInboxServerCommand('snippets', [], async (msg, args, thread) => { + const allSnippets = await snippets.all(); + const triggers = allSnippets.map(s => s.trigger); + triggers.sort(); + + utils.postSystemMessageWithFallback(msg.channel, thread, `Available snippets (prefix ${config.snippetPrefix}):\n${triggers.join(', ')}`); + }); +}; diff --git a/src/modules/suspend.js b/src/modules/suspend.js new file mode 100644 index 0000000..7edd096 --- /dev/null +++ b/src/modules/suspend.js @@ -0,0 +1,77 @@ +const moment = require('moment'); +const threads = require("../data/threads"); +const utils = require('../utils'); +const config = require('../config'); + +const {THREAD_STATUS} = require('../data/constants'); + +module.exports = ({ bot, knex, config, commands }) => { + // Check for threads that are scheduled to be suspended and suspend them + async function applyScheduledSuspensions() { + const threadsToBeSuspended = await threads.getThreadsThatShouldBeSuspended(); + for (const thread of threadsToBeSuspended) { + if (thread.status === THREAD_STATUS.OPEN) { + await thread.suspend(); + await thread.postSystemMessage(`**Thread suspended** as scheduled by ${thread.scheduled_suspend_name}. This thread will act as closed until unsuspended with \`!unsuspend\``); + } + } + } + + async function scheduledSuspendLoop() { + try { + await applyScheduledSuspensions(); + } catch (e) { + console.error(e); + } + + setTimeout(scheduledSuspendLoop, 2000); + } + + scheduledSuspendLoop(); + + commands.addInboxThreadCommand('suspend cancel', [], async (msg, args, thread) => { + // Cancel timed suspend + if (thread.scheduled_suspend_at) { + await thread.cancelScheduledSuspend(); + thread.postSystemMessage(`Cancelled scheduled suspension`); + } else { + thread.postSystemMessage(`Thread is not scheduled to be suspended`); + } + }); + + commands.addInboxThreadCommand('suspend', '[delay:delay]', async (msg, args, thread) => { + if (args.delay) { + const suspendAt = moment.utc().add(args.delay, 'ms'); + await thread.scheduleSuspend(suspendAt.format('YYYY-MM-DD HH:mm:ss'), msg.author); + + thread.postSystemMessage(`Thread will be suspended in ${utils.humanizeDelay(args.delay)}. Use \`${config.prefix}suspend cancel\` to cancel.`); + + return; + } + + await thread.suspend(); + thread.postSystemMessage(`**Thread suspended!** This thread will act as closed until unsuspended with \`!unsuspend\``); + }); + + commands.addInboxServerCommand('unsuspend', [], async (msg, args, thread) => { + if (thread) { + thread.postSystemMessage(`Thread is not suspended`); + return; + } + + thread = await threads.findSuspendedThreadByChannelId(msg.channel.id); + if (! thread) { + thread.postSystemMessage(`Not in a thread`); + return; + } + + const otherOpenThread = await threads.findOpenThreadByUserId(thread.user_id); + if (otherOpenThread) { + thread.postSystemMessage(`Cannot unsuspend; there is another open thread with this user: <#${otherOpenThread.channel_id}>`); + return; + } + + await thread.unsuspend(); + thread.postSystemMessage(`**Thread unsuspended!**`); + }); +}; diff --git a/src/modules/typingProxy.js b/src/modules/typingProxy.js new file mode 100644 index 0000000..5881808 --- /dev/null +++ b/src/modules/typingProxy.js @@ -0,0 +1,33 @@ +const config = require('../config'); +const threads = require("../data/threads"); +const Eris = require("eris"); + +module.exports = ({ bot }) => { + // Typing proxy: forwarding typing events between the DM and the modmail thread + if(config.typingProxy || config.typingProxyReverse) { + bot.on("typingStart", async (channel, user) => { + // config.typingProxy: forward user typing in a DM to the modmail thread + if (config.typingProxy && (channel instanceof Eris.PrivateChannel)) { + const thread = await threads.findOpenThreadByUserId(user.id); + if (! thread) return; + + try { + await bot.sendChannelTyping(thread.channel_id); + } catch (e) {} + } + + // config.typingProxyReverse: forward moderator typing in a thread to the DM + else if (config.typingProxyReverse && (channel instanceof Eris.GuildChannel) && ! user.bot) { + const thread = await threads.findByChannelId(channel.id); + if (! thread) return; + + const dmChannel = await thread.getDMChannel(); + if (! dmChannel) return; + + try { + await bot.sendChannelTyping(dmChannel.id); + } catch(e) {} + } + }); + } +}; diff --git a/src/modules/version.js b/src/modules/version.js new file mode 100644 index 0000000..033fbf0 --- /dev/null +++ b/src/modules/version.js @@ -0,0 +1,53 @@ +const path = require('path'); +const fs = require('fs'); +const {promisify} = require('util'); +const utils = require("../utils"); +const updates = require('../data/updates'); +const config = require('../config'); + +const access = promisify(fs.access); +const readFile = promisify(fs.readFile); + +const GIT_DIR = path.join(__dirname, '..', '..', '.git'); + +module.exports = ({ bot, knex, config, commands }) => { + commands.addInboxServerCommand('version', [], async (msg, args, thread) => { + const packageJson = require('../../package.json'); + const packageVersion = packageJson.version; + + let response = `Modmail v${packageVersion}`; + + let isGit; + try { + await access(GIT_DIR); + isGit = true; + } catch (e) { + isGit = false; + } + + if (isGit) { + let commitHash; + const HEAD = await readFile(path.join(GIT_DIR, 'HEAD'), {encoding: 'utf8'}); + + if (HEAD.startsWith('ref:')) { + // Branch + const ref = HEAD.match(/^ref: (.*)$/m)[1]; + commitHash = (await readFile(path.join(GIT_DIR, ref), {encoding: 'utf8'})).trim(); + } else { + // Detached head + commitHash = HEAD.trim(); + } + + response += ` (${commitHash.slice(0, 7)})`; + } + + if (config.updateNotifications) { + const availableUpdate = await updates.getAvailableUpdate(); + if (availableUpdate) { + response += ` (version ${availableUpdate} available)`; + } + } + + utils.postSystemMessageWithFallback(msg.channel, thread, response); + }); +}; diff --git a/src/modules/webserver.js b/src/modules/webserver.js new file mode 100644 index 0000000..5d8b2c9 --- /dev/null +++ b/src/modules/webserver.js @@ -0,0 +1,92 @@ +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.utc(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.getLocalAttachmentPath(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.getType(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.on('error', err => { + console.log('[WARN] Web server error:', err.message); + }); + + server.listen(config.port); +}; diff --git a/src/plugins.js b/src/plugins.js new file mode 100644 index 0000000..64c9df6 --- /dev/null +++ b/src/plugins.js @@ -0,0 +1,26 @@ +const attachments = require('./data/attachments'); + +module.exports = { + getPluginAPI({ bot, knex, config, commands }) { + return { + bot, + knex, + config, + commands: { + manager: commands.manager, + addGlobalCommand: commands.addGlobalCommand, + addInboxServerCommand: commands.addInboxServerCommand, + addInboxThreadCommand: commands.addInboxThreadCommand, + addAlias: commands.addAlias + }, + attachments: { + addStorageType: attachments.addStorageType, + downloadAttachment: attachments.downloadAttachment + }, + }; + }, + + loadPlugin(plugin, api) { + plugin(api); + } +}; diff --git a/src/queue.js b/src/queue.js new file mode 100644 index 0000000..589425e --- /dev/null +++ b/src/queue.js @@ -0,0 +1,40 @@ +class Queue { + constructor() { + this.running = false; + this.queue = []; + } + + add(fn) { + const promise = new Promise(resolve => { + this.queue.push(async () => { + await Promise.resolve(fn()); + resolve(); + }); + + if (! this.running) this.next(); + }); + + return promise; + } + + next() { + this.running = true; + + if (this.queue.length === 0) { + this.running = false; + return; + } + + const fn = this.queue.shift(); + new Promise(resolve => { + // Either fn() completes or the timeout of 10sec is reached + fn().then(resolve); + setTimeout(resolve, 10000); + }).then(() => this.next()); + } +} + +module.exports = { + Queue, + messageQueue: new Queue() +}; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..3e475ab --- /dev/null +++ b/src/utils.js @@ -0,0 +1,357 @@ +const Eris = require('eris'); +const bot = require('./bot'); +const moment = require('moment'); +const humanizeDuration = require('humanize-duration'); +const publicIp = require('public-ip'); +const config = require('./config'); + +class BotError extends Error {} + +const userMentionRegex = /^<@!?([0-9]+?)>$/; + +let inboxGuild = null; +let mainGuilds = []; +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 getMainGuilds() { + if (mainGuilds.length === 0) { + mainGuilds = bot.guilds.filter(g => config.mainGuildId.includes(g.id)); + } + + if (mainGuilds.length !== config.mainGuildId.length) { + if (config.mainGuildId.length === 1) { + console.warn(`[WARN] The bot hasn't joined the main guild!`); + } else { + console.warn(`[WARN] The bot hasn't joined one or more main guilds!`); + } + } + + return mainGuilds; +} + +/** + * Returns the designated log channel, or the default channel if none is set + * @returns {Eris~TextChannel} + */ +function getLogChannel() { + const inboxGuild = getInboxGuild(); + const logChannel = inboxGuild.channels.get(config.logChannelId); + + if (! logChannel) { + throw new BotError('Log channel (logChannelId) not found!'); + } + + if (! (logChannel instanceof Eris.TextChannel)) { + throw new BotError('Make sure the logChannelId option is set to a text channel!'); + } + + return logChannel; +} + +function postLog(...args) { + getLogChannel().createMessage(...args); +} + +function postError(channel, str, opts = {}) { + return channel.createMessage({ + ...opts, + content: `⚠ ${str}` + }); +} + +/** + * Returns whether the given member has permission to use modmail commands + * @param member + * @returns {boolean} + */ +function isStaff(member) { + if (! member) return false; + if (config.inboxServerPermission.length === 0) return true; + + return config.inboxServerPermission.some(perm => { + if (isSnowflake(perm)) { + // If perm is a snowflake, check it against the member's user id and roles + if (member.id === perm) return true; + if (member.roles.includes(perm)) return true; + } else { + // Otherwise assume perm is the name of a permission + return member.permission.has(perm); + } + + return false; + }); +} + +/** + * Returns whether the given message is on the inbox server + * @param msg + * @returns {boolean} + */ +function messageIsOnInboxServer(msg) { + if (! msg.channel.guild) return false; + if (msg.channel.guild.id !== getInboxGuild().id) return false; + return true; +} + +/** + * Returns whether the given message is on the main server + * @param msg + * @returns {boolean} + */ +function messageIsOnMainServer(msg) { + if (! msg.channel.guild) return false; + + return getMainGuilds() + .some(g => msg.channel.guild.id === g.id); +} + +/** + * @param attachment + * @returns {Promise<string>} + */ +async function formatAttachment(attachment, attachmentUrl) { + let filesize = attachment.size || 0; + filesize /= 1024; + + return `**Attachment:** ${attachment.filename} (${filesize.toFixed(1)}KB)\n${attachmentUrl}`; +} + +/** + * Returns the user ID of the user mentioned in str, if any + * @param {String} str + * @returns {String|null} + */ +function getUserMention(str) { + if (! str) return null; + + str = str.trim(); + + if (isSnowflake(str)) { + // User ID + return str; + } else { + let mentionMatch = str.match(userMentionRegex); + if (mentionMatch) return mentionMatch[1]; + } + + return null; +} + +/** + * Returns the current timestamp in an easily readable form + * @returns {String} + */ +function getTimestamp(...momentArgs) { + return moment.utc(...momentArgs).format('HH:mm'); +} + +/** + * Disables link previews in the given string by wrapping links in < > + * @param {String} str + * @returns {String} + */ +function disableLinkPreviews(str) { + return str.replace(/(^|[^<])(https?:\/\/\S+)/ig, '$1<$2>'); +} + +/** + * Returns a URL to the bot's web server + * @param {String} path + * @returns {Promise<String>} + */ +async function getSelfUrl(path = '') { + if (config.url) { + return `${config.url}/${path}`; + } else { + const port = config.port || 8890; + const ip = await publicIp.v4(); + return `http://${ip}:${port}/${path}`; + } +} + +/** + * Returns the highest hoisted role of the given member + * @param {Eris~Member} member + * @returns {Eris~Role} + */ +function getMainRole(member) { + const roles = member.roles.map(id => member.guild.roles.get(id)); + roles.sort((a, b) => a.position > b.position ? -1 : 1); + return roles.find(r => r.hoist); +} + +/** + * Splits array items into chunks of the specified size + * @param {Array|String} items + * @param {Number} chunkSize + * @returns {Array} + */ +function chunk(items, chunkSize) { + const result = []; + + for (let i = 0; i < items.length; i += chunkSize) { + result.push(items.slice(i, i + chunkSize)); + } + + return result; +} + +/** + * Trims every line in the string + * @param {String} str + * @returns {String} + */ +function trimAll(str) { + return str + .split('\n') + .map(str => str.trim()) + .join('\n'); +} + +const delayStringRegex = /^([0-9]+)(?:([dhms])[a-z]*)?/i; + +/** + * Turns a "delay string" such as "1h30m" to milliseconds + * @param {String} str + * @returns {Number|null} + */ +function convertDelayStringToMS(str) { + let match; + let ms = 0; + + str = str.trim(); + + while (str !== '' && (match = str.match(delayStringRegex)) !== null) { + if (match[2] === 'd') ms += match[1] * 1000 * 60 * 60 * 24; + else if (match[2] === 'h') ms += match[1] * 1000 * 60 * 60; + else if (match[2] === 's') ms += match[1] * 1000; + else if (match[2] === 'm' || ! match[2]) ms += match[1] * 1000 * 60; + + str = str.slice(match[0].length); + } + + // Invalid delay string + if (str !== '') { + return null; + } + + return ms; +} + +function getInboxMention() { + const mentionRoles = Array.isArray(config.mentionRole) ? config.mentionRole : [config.mentionRole]; + const mentions = []; + for (const role of mentionRoles) { + if (role == null) continue; + else if (role === 'here') mentions.push('@here'); + else if (role === 'everyone') mentions.push('@everyone'); + else mentions.push(`<@&${role}>`); + } + return mentions.join(' ') + ' '; +} + +function postSystemMessageWithFallback(channel, thread, text) { + if (thread) { + thread.postSystemMessage(text); + } else { + channel.createMessage(text); + } +} + +/** + * A normalized way to set props in data models, fixing some inconsistencies between different DB drivers in knex + * @param {Object} target + * @param {Object} props + */ +function setDataModelProps(target, props) { + for (const prop in props) { + if (! props.hasOwnProperty(prop)) continue; + // DATETIME fields are always returned as Date objects in MySQL/MariaDB + if (props[prop] instanceof Date) { + // ...even when NULL, in which case the date's set to unix epoch + if (props[prop].getUTCFullYear() === 1970) { + target[prop] = null; + } else { + // Set the value as a string in the same format it's returned in SQLite + target[prop] = moment.utc(props[prop]).format('YYYY-MM-DD HH:mm:ss'); + } + } else { + target[prop] = props[prop]; + } + } +} + +const snowflakeRegex = /^[0-9]{17,}$/; +function isSnowflake(str) { + return str && snowflakeRegex.test(str); +} + +const humanizeDelay = (delay, opts = {}) => humanizeDuration(delay, Object.assign({conjunction: ' and '}, opts)); + +const markdownCharsRegex = /([\\_*|`~])/g; +function escapeMarkdown(str) { + return str.replace(markdownCharsRegex, '\\$1'); +} + +function disableCodeBlocks(str) { + return str.replace(/`/g, "`\u200b"); +} + +/** + * + */ +function readMultilineConfigValue(str) { + return Array.isArray(str) ? str.join('\n') : str; +} + +module.exports = { + BotError, + + getInboxGuild, + getMainGuilds, + getLogChannel, + postError, + postLog, + + isStaff, + messageIsOnInboxServer, + messageIsOnMainServer, + + formatAttachment, + + getUserMention, + getTimestamp, + disableLinkPreviews, + getSelfUrl, + getMainRole, + delayStringRegex, + convertDelayStringToMS, + getInboxMention, + postSystemMessageWithFallback, + + chunk, + trimAll, + + setDataModelProps, + + isSnowflake, + + humanizeDelay, + + escapeMarkdown, + disableCodeBlocks, + + readMultilineConfigValue, +}; |