From 751b18a12d3b6a2ee729a40f84775caae731ab28 Mon Sep 17 00:00:00 2001 From: Dragory Date: Sun, 11 Mar 2018 21:32:14 +0200 Subject: [PATCH] Add scheduled thread closing A thread can be scheduled to be closed by adding a time parameter to the !close command. For example, !close 2m would automatically close the thread in 2 minutes. The actual scheduling is implemented with a loop that runs every 2 seconds, checking for threads that should be closed. --- .../20180224235946_add_close_at_to_threads.js | 15 +++++ src/data/Thread.js | 47 ++++++++++++++-- src/data/threads.js | 12 ++++ src/main.js | 56 ++++++++++++++++++- src/utils.js | 25 +++++++++ 5 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 db/migrations/20180224235946_add_close_at_to_threads.js diff --git a/db/migrations/20180224235946_add_close_at_to_threads.js b/db/migrations/20180224235946_add_close_at_to_threads.js new file mode 100644 index 0000000..dbbb04d --- /dev/null +++ b/db/migrations/20180224235946_add_close_at_to_threads.js @@ -0,0 +1,15 @@ +exports.up = async function (knex, Promise) { + await knex.schema.table('threads', table => { + table.dateTime('scheduled_close_at').index().nullable().defaultTo(null).after('channel_id'); + table.string('scheduled_close_id', 20).nullable().defaultTo(null).after('channel_id'); + table.string('scheduled_close_name', 128).nullable().defaultTo(null).after('channel_id'); + }); +}; + +exports.down = async function(knex, Promise) { + await knex.schema.table('threads', table => { + table.dropColumn('scheduled_close_at'); + table.dropColumn('scheduled_close_id'); + table.dropColumn('scheduled_close_name'); + }); +}; diff --git a/src/data/Thread.js b/src/data/Thread.js index e26e36f..dea79c8 100644 --- a/src/data/Thread.js +++ b/src/data/Thread.js @@ -1,6 +1,4 @@ -const fs = require("fs"); const moment = require('moment'); -const {promisify} = require('util'); const bot = require('../bot'); const knex = require('../knex'); @@ -18,6 +16,9 @@ const {THREAD_MESSAGE_TYPE, THREAD_STATUS} = require('./constants'); * @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 {String} created_at */ class Thread { @@ -89,6 +90,11 @@ class Thread { 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`); + } } /** @@ -136,6 +142,14 @@ class Thread { is_anonymous: 0, dm_message_id: msg.id }); + + if (this.scheduled_close_at) { + await this.cancelScheduledClose(); + await this.postSystemMessage({ + content: `<@!${this.scheduled_close_id}> Thread that was scheduled to be closed got a new reply. Cancelling.`, + disableEveryone: false + }); + } } /** @@ -181,22 +195,23 @@ class Thread { /** * @param {String} text + * @param {*} args * @returns {Promise} */ - async postSystemMessage(text) { - const msg = await this.postToThreadChannel(text); + 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: text, + body: typeof text === 'string' ? text : text.content, is_anonymous: 0, dm_message_id: msg.id }); } /** - * @param {String} text + * @param {*} args * @returns {Promise} */ async postNonLogMessage(...args) { @@ -292,6 +307,26 @@ class Thread { } } + async scheduleClose(time, user) { + await knex('threads') + .where('id', this.id) + .update({ + scheduled_close_at: time, + scheduled_close_id: user.id, + scheduled_close_name: user.username + }); + } + + async cancelScheduledClose() { + await knex('threads') + .where('id', this.id) + .update({ + scheduled_close_at: null, + scheduled_close_id: null, + scheduled_close_name: null + }); + } + /** * @returns {Promise} */ diff --git a/src/data/threads.js b/src/data/threads.js index 58fd71c..21ca3aa 100644 --- a/src/data/threads.js +++ b/src/data/threads.js @@ -199,6 +199,17 @@ async function findOrCreateThreadForUser(user) { 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) + .select(); + + return threads.map(thread => new Thread(thread)); +} + module.exports = { findById, findOpenThreadByUserId, @@ -207,5 +218,6 @@ module.exports = { createNewThreadForUser, getClosedThreadsByUserId, findOrCreateThreadForUser, + getThreadsThatShouldBeClosed, createThreadInDB }; diff --git a/src/main.js b/src/main.js index 5c665b5..ef61382 100644 --- a/src/main.js +++ b/src/main.js @@ -164,7 +164,7 @@ if(config.typingProxy || config.typingProxyReverse) { const thread = await threads.findByChannelId(channel.id); if (! thread) return; - const dmChannel = await thread.getDMChannel(thread.user_id); + const dmChannel = await thread.getDMChannel(); if (! dmChannel) return; try { @@ -174,6 +174,32 @@ if(config.typingProxy || config.typingProxyReverse) { }); } +// 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) { + await thread.close(); + + 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 closeLoop() { + try { + await applyScheduledCloses(); + } catch (e) { + console.error(e); + } + + setTimeout(closeLoop, 2000); +} + +closeLoop(); + // 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 addInboxServerCommand('reply', async (msg, args, thread) => { @@ -202,6 +228,34 @@ bot.registerCommandAlias('ar', 'anonreply'); // Close a thread. Closing a thread saves a log of the channel's contents and then deletes the channel. addInboxServerCommand('close', async (msg, args, thread) => { if (! thread) return; + + // Timed close + if (args.length) { + if (args[0] === 'cancel') { + // Cancel timed close + if (thread.scheduled_close_at) { + await thread.cancelScheduledClose(); + thread.postSystemMessage(`Cancelled scheduled closing`); + } + + return; + } + + // Set a timed close + const delay = utils.convertDelayStringToMS(args.join(' ')); + if (delay === 0) { + thread.postNonLogMessage(`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); + thread.postSystemMessage(`Thread is scheduled to be closed ${moment.duration(delay).humanize(true)} by ${msg.author.username}. Use \`${config.prefix}close cancel\` to cancel.`); + + return; + } + + // Regular close await thread.close(); const logUrl = await thread.getLogUrl(); diff --git a/src/utils.js b/src/utils.js index 80fb790..c75bc34 100644 --- a/src/utils.js +++ b/src/utils.js @@ -185,6 +185,11 @@ function chunk(items, chunkSize) { return result; } +/** + * Trims every line in the string + * @param {String} str + * @returns {String} + */ function trimAll(str) { return str .split('\n') @@ -192,6 +197,25 @@ function trimAll(str) { .join('\n'); } +/** + * Turns a "delay string" such as "1h30m" to milliseconds + * @param {String} str + * @returns {Number} + */ +function convertDelayStringToMS(str) { + const regex = /([0-9]+)\s*([hms])/g; + let match; + let ms = 0; + + while (match = regex.exec(str)) { + if (match[2] === 'h') ms += match[1] * 1000 * 60 * 60; + else if (match[2] === 'm') ms += match[1] * 1000 * 60; + else if (match[2] === 's') ms += match[1] * 1000; + } + + return ms; +} + module.exports = { BotError, @@ -212,6 +236,7 @@ module.exports = { disableLinkPreviews, getSelfUrl, getMainRole, + convertDelayStringToMS, chunk, trimAll,