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