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
parent
13a17efe4e
commit
751b18a12d
|
@ -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');
|
||||||
|
});
|
||||||
|
};
|
|
@ -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>}
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
56
src/main.js
56
src/main.js
|
@ -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();
|
||||||
|
|
25
src/utils.js
25
src/utils.js
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue