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.
master
Dragory 2018-03-11 21:32:14 +02:00
parent 13a17efe4e
commit 751b18a12d
5 changed files with 148 additions and 7 deletions

View File

@ -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');
});
};

View File

@ -1,6 +1,4 @@
const fs = require("fs");
const moment = require('moment'); const moment = require('moment');
const {promisify} = require('util');
const bot = require('../bot'); const bot = require('../bot');
const knex = require('../knex'); const knex = require('../knex');
@ -18,6 +16,9 @@ const {THREAD_MESSAGE_TYPE, THREAD_STATUS} = require('./constants');
* @property {String} user_id * @property {String} user_id
* @property {String} user_name * @property {String} user_name
* @property {String} channel_id * @property {String} channel_id
* @property {String} scheduled_close_at
* @property {String} scheduled_close_id
* @property {String} scheduled_close_name
* @property {String} created_at * @property {String} created_at
*/ */
class Thread { class Thread {
@ -89,6 +90,11 @@ class Thread {
is_anonymous: (isAnonymous ? 1 : 0), is_anonymous: (isAnonymous ? 1 : 0),
dm_message_id: dmMessage.id 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, is_anonymous: 0,
dm_message_id: msg.id 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 {String} text
* @param {*} args
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async postSystemMessage(text) { async postSystemMessage(text, ...args) {
const msg = await this.postToThreadChannel(text); const msg = await this.postToThreadChannel(text, ...args);
await this.addThreadMessageToDB({ await this.addThreadMessageToDB({
message_type: THREAD_MESSAGE_TYPE.SYSTEM, message_type: THREAD_MESSAGE_TYPE.SYSTEM,
user_id: null, user_id: null,
user_name: '', user_name: '',
body: text, body: typeof text === 'string' ? text : text.content,
is_anonymous: 0, is_anonymous: 0,
dm_message_id: msg.id dm_message_id: msg.id
}); });
} }
/** /**
* @param {String} text * @param {*} args
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async postNonLogMessage(...args) { 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<String>} * @returns {Promise<String>}
*/ */

View File

@ -199,6 +199,17 @@ async function findOrCreateThreadForUser(user) {
return createNewThreadForUser(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 = { module.exports = {
findById, findById,
findOpenThreadByUserId, findOpenThreadByUserId,
@ -207,5 +218,6 @@ module.exports = {
createNewThreadForUser, createNewThreadForUser,
getClosedThreadsByUserId, getClosedThreadsByUserId,
findOrCreateThreadForUser, findOrCreateThreadForUser,
getThreadsThatShouldBeClosed,
createThreadInDB createThreadInDB
}; };

View File

@ -164,7 +164,7 @@ if(config.typingProxy || config.typingProxyReverse) {
const thread = await threads.findByChannelId(channel.id); const thread = await threads.findByChannelId(channel.id);
if (! thread) return; if (! thread) return;
const dmChannel = await thread.getDMChannel(thread.user_id); const dmChannel = await thread.getDMChannel();
if (! dmChannel) return; if (! dmChannel) return;
try { 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 // 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 // These messages get relayed back to the DM thread between the bot and the user
addInboxServerCommand('reply', async (msg, args, thread) => { 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. // 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) => { addInboxServerCommand('close', async (msg, args, thread) => {
if (! thread) return; 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(); await thread.close();
const logUrl = await thread.getLogUrl(); const logUrl = await thread.getLogUrl();

View File

@ -185,6 +185,11 @@ function chunk(items, chunkSize) {
return result; return result;
} }
/**
* Trims every line in the string
* @param {String} str
* @returns {String}
*/
function trimAll(str) { function trimAll(str) {
return str return str
.split('\n') .split('\n')
@ -192,6 +197,25 @@ function trimAll(str) {
.join('\n'); .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 = { module.exports = {
BotError, BotError,
@ -212,6 +236,7 @@ module.exports = {
disableLinkPreviews, disableLinkPreviews,
getSelfUrl, getSelfUrl,
getMainRole, getMainRole,
convertDelayStringToMS,
chunk, chunk,
trimAll, trimAll,