aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSin-MacBook <[email protected]>2020-08-10 23:44:20 +0200
committerSin-MacBook <[email protected]>2020-08-10 23:44:20 +0200
commit2a53887abba882bf7b63aeda8dfa55fdb3ab8792 (patch)
treead7a95eb41faa6ff13c3142285cdc0eb3ca92183 /src
downloadmodmail-2a53887abba882bf7b63aeda8dfa55fdb3ab8792.tar.xz
modmail-2a53887abba882bf7b63aeda8dfa55fdb3ab8792.zip
clean this up when home
Diffstat (limited to 'src')
-rw-r--r--src/bot.js9
-rw-r--r--src/commands.js130
-rw-r--r--src/config.js289
-rw-r--r--src/data/Snippet.js15
-rw-r--r--src/data/Thread.js468
-rw-r--r--src/data/ThreadMessage.js20
-rw-r--r--src/data/attachments.js202
-rw-r--r--src/data/blocked.js94
-rw-r--r--src/data/constants.js55
-rw-r--r--src/data/snippets.js58
-rw-r--r--src/data/threads.js381
-rw-r--r--src/data/updates.js115
-rw-r--r--src/index.js93
-rw-r--r--src/knex.js2
-rw-r--r--src/legacy/jsonDb.js71
-rw-r--r--src/legacy/legacyMigrator.js222
-rw-r--r--src/main.js281
-rw-r--r--src/modules/alert.js11
-rw-r--r--src/modules/block.js99
-rw-r--r--src/modules/close.js153
-rw-r--r--src/modules/greeting.js37
-rw-r--r--src/modules/id.js5
-rw-r--r--src/modules/logs.js69
-rw-r--r--src/modules/move.js82
-rw-r--r--src/modules/newthread.js23
-rw-r--r--src/modules/reply.js32
-rw-r--r--src/modules/snippets.js138
-rw-r--r--src/modules/suspend.js77
-rw-r--r--src/modules/typingProxy.js33
-rw-r--r--src/modules/version.js53
-rw-r--r--src/modules/webserver.js92
-rw-r--r--src/plugins.js26
-rw-r--r--src/queue.js40
-rw-r--r--src/utils.js357
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,
+};