2017-12-24 15:04:08 -05:00
|
|
|
const Eris = require('eris');
|
|
|
|
const transliterate = require('transliteration');
|
|
|
|
const moment = require('moment');
|
|
|
|
const uuid = require('uuid');
|
2017-12-31 19:16:05 -05:00
|
|
|
const humanizeDuration = require('humanize-duration');
|
2017-12-24 15:04:08 -05:00
|
|
|
|
2019-03-27 22:29:40 -04:00
|
|
|
const bot = require('../bot');
|
2017-12-24 15:04:08 -05:00
|
|
|
const knex = require('../knex');
|
|
|
|
const config = require('../config');
|
2017-12-31 19:16:05 -05:00
|
|
|
const utils = require('../utils');
|
2017-12-24 15:04:08 -05:00
|
|
|
|
2017-12-31 19:16:05 -05:00
|
|
|
const Thread = require('./Thread');
|
|
|
|
const {THREAD_STATUS} = require('./constants');
|
2017-12-24 15:04:08 -05:00
|
|
|
|
2018-02-14 01:53:34 -05:00
|
|
|
/**
|
|
|
|
* @param {String} id
|
|
|
|
* @returns {Promise<Thread>}
|
|
|
|
*/
|
2018-02-11 14:54:30 -05:00
|
|
|
async function findById(id) {
|
|
|
|
const thread = await knex('threads')
|
|
|
|
.where('id', id)
|
|
|
|
.first();
|
|
|
|
|
|
|
|
return (thread ? new Thread(thread) : null);
|
|
|
|
}
|
|
|
|
|
2017-12-24 15:04:08 -05:00
|
|
|
/**
|
2017-12-31 19:16:05 -05:00
|
|
|
* @param {String} userId
|
2017-12-24 15:04:08 -05:00
|
|
|
* @returns {Promise<Thread>}
|
|
|
|
*/
|
2017-12-31 19:16:05 -05:00
|
|
|
async function findOpenThreadByUserId(userId) {
|
2017-12-24 15:04:08 -05:00
|
|
|
const thread = await knex('threads')
|
2017-12-31 19:16:05 -05:00
|
|
|
.where('user_id', userId)
|
2017-12-24 15:04:08 -05:00
|
|
|
.where('status', THREAD_STATUS.OPEN)
|
2018-02-11 14:54:30 -05:00
|
|
|
.first();
|
2017-12-24 15:04:08 -05:00
|
|
|
|
2017-12-31 19:16:05 -05:00
|
|
|
return (thread ? new Thread(thread) : null);
|
|
|
|
}
|
2017-12-24 15:04:08 -05:00
|
|
|
|
2018-04-21 08:38:21 -04:00
|
|
|
function getHeaderGuildInfo(member) {
|
|
|
|
return {
|
|
|
|
nickname: member.nick || member.user.username,
|
|
|
|
joinDate: humanizeDuration(Date.now() - member.joinedAt, {largest: 2, round: true})
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2017-12-31 19:16:05 -05:00
|
|
|
/**
|
|
|
|
* Creates a new modmail thread for the specified user
|
|
|
|
* @param {Eris.User} user
|
2018-04-07 19:56:30 -04:00
|
|
|
* @param {Boolean} quiet If true, doesn't ping mentionRole or reply with responseMessage
|
2017-12-31 19:16:05 -05:00
|
|
|
* @returns {Promise<Thread>}
|
|
|
|
* @throws {Error}
|
|
|
|
*/
|
2019-04-15 09:43:22 -04:00
|
|
|
async function createNewThreadForUser(user, member, quiet = false) {
|
2017-12-31 19:16:05 -05:00
|
|
|
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!');
|
2017-12-24 15:04:08 -05:00
|
|
|
}
|
|
|
|
|
2019-04-15 09:43:22 -04:00
|
|
|
// Check the config for a requirement of a minimum time the user must be a member of the guild to contact modmail,
|
|
|
|
// if the user hasn't been a member of the guild for the specified time, return an optional message without making a new thread
|
|
|
|
if (config.requiredJoinedAt && member) {
|
|
|
|
if (member.joinedAt > moment() - config.requiredJoinedAt) {
|
|
|
|
if (config.joinedAtDeniedMessage) {
|
|
|
|
const privateChannel = await user.getDMChannel();
|
|
|
|
await privateChannel.createMessage(config.joinedAtDeniedMessage);
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-07-10 05:59:29 -04:00
|
|
|
// Check the config for a requirement of account age to contact modmail,
|
|
|
|
// if the account is too young, return an optional message without making a new thread
|
2018-07-27 12:48:45 -04:00
|
|
|
if (config.requiredAccountAge) {
|
2018-07-27 13:35:20 -04:00
|
|
|
if (user.createdAt > moment() - config.requiredAccountAge * 3600000){
|
2018-07-27 12:48:45 -04:00
|
|
|
if (config.accountAgeDeniedMessage) {
|
2018-07-10 05:59:29 -04:00
|
|
|
const privateChannel = await user.getDMChannel();
|
2018-07-27 12:48:45 -04:00
|
|
|
await privateChannel.createMessage(config.accountAgeDeniedMessage);
|
2018-07-10 05:59:29 -04:00
|
|
|
}
|
|
|
|
return;
|
2018-07-27 12:48:45 -04:00
|
|
|
}
|
2018-07-10 05:59:29 -04:00
|
|
|
}
|
|
|
|
|
2017-12-24 15:04:08 -05:00
|
|
|
// 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}`);
|
|
|
|
|
2019-03-27 22:54:12 -04:00
|
|
|
// 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 });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
}
|
|
|
|
|
2017-12-24 15:04:08 -05:00
|
|
|
// Attempt to create the inbox channel for this thread
|
|
|
|
let createdChannel;
|
|
|
|
try {
|
2019-03-27 22:54:12 -04:00
|
|
|
createdChannel = await utils.getInboxGuild().createChannel(channelName, null, 'New ModMail thread', newThreadCategoryId);
|
2017-12-24 15:04:08 -05:00
|
|
|
} catch (err) {
|
|
|
|
console.error(`Error creating modmail channel for ${user.username}#${user.discriminator}!`);
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Save the new thread in the database
|
2017-12-31 19:16:05 -05:00
|
|
|
const newThreadId = await createThreadInDB({
|
2017-12-24 15:04:08 -05:00
|
|
|
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')
|
|
|
|
});
|
|
|
|
|
2017-12-31 19:16:05 -05:00
|
|
|
const newThread = await findById(newThreadId);
|
2018-05-03 13:26:12 -04:00
|
|
|
let responseMessageError = null;
|
2017-12-31 19:16:05 -05:00
|
|
|
|
2018-04-07 19:56:30 -04:00
|
|
|
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) {
|
2018-05-03 13:26:12 -04:00
|
|
|
try {
|
|
|
|
await newThread.postToUser(config.responseMessage);
|
|
|
|
} catch (err) {
|
|
|
|
responseMessageError = err;
|
|
|
|
}
|
2018-04-07 19:56:30 -04:00
|
|
|
}
|
2018-02-14 01:53:34 -05:00
|
|
|
}
|
|
|
|
|
2017-12-31 19:16:05 -05:00
|
|
|
// Post some info to the beginning of the new thread
|
2018-04-21 08:38:21 -04:00
|
|
|
const infoHeaderItems = [];
|
|
|
|
|
|
|
|
// Account age
|
|
|
|
const accountAge = humanizeDuration(Date.now() - user.createdAt, {largest: 2, round: true});
|
|
|
|
infoHeaderItems.push(`ACCOUNT AGE **${accountAge}**`);
|
|
|
|
|
2018-09-20 15:03:28 -04:00
|
|
|
// User id (and mention, if enabled)
|
|
|
|
if (config.mentionUserInThreadHeader) {
|
|
|
|
infoHeaderItems.push(`ID **${user.id}** (<@!${user.id}>)`);
|
|
|
|
} else {
|
|
|
|
infoHeaderItems.push(`ID **${user.id}**`);
|
|
|
|
}
|
2018-04-21 08:38:21 -04:00
|
|
|
|
|
|
|
let infoHeader = infoHeaderItems.join(', ');
|
|
|
|
|
2019-03-27 22:29:40 -04:00
|
|
|
// Guild member info
|
2019-03-27 22:54:12 -04:00
|
|
|
for (const [guildId, guildData] of userGuildData.entries()) {
|
|
|
|
const {nickname, joinDate} = getHeaderGuildInfo(guildData.member);
|
2019-03-27 22:57:21 -04:00
|
|
|
const headerItems = [
|
2019-03-27 23:03:47 -04:00
|
|
|
`NICKNAME **${utils.escapeMarkdown(nickname)}**`,
|
2019-03-27 22:54:12 -04:00
|
|
|
`JOINED **${joinDate}** ago`
|
2019-03-27 22:57:21 -04:00
|
|
|
];
|
|
|
|
|
|
|
|
if (guildData.member.voiceState.channelID) {
|
|
|
|
const voiceChannel = guildData.guild.channels.get(guildData.member.voiceState.channelID);
|
|
|
|
if (voiceChannel) {
|
2019-03-27 23:03:47 -04:00
|
|
|
headerItems.push(`VOICE CHANNEL **${utils.escapeMarkdown(voiceChannel.name)}**`);
|
2019-03-27 22:57:21 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const headerStr = headerItems.join(', ');
|
2019-03-27 22:54:12 -04:00
|
|
|
|
|
|
|
if (mainGuilds.length === 1) {
|
|
|
|
infoHeader += `\n${headerStr}`;
|
|
|
|
} else {
|
2019-03-27 23:03:47 -04:00
|
|
|
infoHeader += `\n**[${utils.escapeMarkdown(guildData.guild.name)}]** ${headerStr}`;
|
2018-04-21 08:38:21 -04:00
|
|
|
}
|
2019-03-27 22:29:40 -04:00
|
|
|
}
|
2017-12-31 19:16:05 -05:00
|
|
|
|
2019-03-27 22:29:40 -04:00
|
|
|
// ModMail history / previous logs
|
2017-12-31 19:16:05 -05:00
|
|
|
const userLogCount = await getClosedThreadCountByUserId(user.id);
|
2018-04-21 08:38:21 -04:00
|
|
|
if (userLogCount > 0) {
|
|
|
|
infoHeader += `\n\nThis user has **${userLogCount}** previous modmail threads. Use \`${config.prefix}logs\` to see them.`;
|
|
|
|
}
|
|
|
|
|
|
|
|
infoHeader += '\n────────────────';
|
2017-12-31 19:16:05 -05:00
|
|
|
|
|
|
|
await newThread.postSystemMessage(infoHeader);
|
|
|
|
|
2018-05-03 13:26:12 -04:00
|
|
|
// 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}\``);
|
|
|
|
}
|
|
|
|
|
2017-12-31 19:16:05 -05:00
|
|
|
// Return the thread
|
|
|
|
return newThread;
|
2017-12-24 15:04:08 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates a new thread row in the database
|
|
|
|
* @param {Object} data
|
|
|
|
* @returns {Promise<String>} The ID of the created thread
|
|
|
|
*/
|
2017-12-31 19:16:05 -05:00
|
|
|
async function createThreadInDB(data) {
|
2017-12-24 15:04:08 -05:00
|
|
|
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});
|
|
|
|
|
2017-12-31 19:16:05 -05:00
|
|
|
await knex('threads').insert(finalData);
|
2017-12-24 15:04:08 -05:00
|
|
|
|
|
|
|
return threadId;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {String} channelId
|
|
|
|
* @returns {Promise<Thread>}
|
|
|
|
*/
|
2017-12-31 19:16:05 -05:00
|
|
|
async function findByChannelId(channelId) {
|
2017-12-24 15:04:08 -05:00
|
|
|
const thread = await knex('threads')
|
|
|
|
.where('channel_id', channelId)
|
|
|
|
.first();
|
|
|
|
|
|
|
|
return (thread ? new Thread(thread) : null);
|
|
|
|
}
|
|
|
|
|
2018-02-11 14:54:30 -05:00
|
|
|
/**
|
|
|
|
* @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);
|
|
|
|
}
|
|
|
|
|
2018-03-11 16:27:52 -04:00
|
|
|
/**
|
|
|
|
* @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);
|
|
|
|
}
|
|
|
|
|
2017-12-24 15:04:08 -05:00
|
|
|
/**
|
2017-12-31 19:16:05 -05:00
|
|
|
* @param {String} userId
|
|
|
|
* @returns {Promise<Thread[]>}
|
2017-12-24 15:04:08 -05:00
|
|
|
*/
|
2017-12-31 19:16:05 -05:00
|
|
|
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));
|
2017-12-24 15:04:08 -05:00
|
|
|
}
|
|
|
|
|
2017-12-31 19:16:05 -05:00
|
|
|
/**
|
|
|
|
* @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'));
|
2017-12-24 15:04:08 -05:00
|
|
|
|
2017-12-31 19:16:05 -05:00
|
|
|
return parseInt(row.thread_count, 10);
|
|
|
|
}
|
|
|
|
|
2018-02-11 14:54:30 -05:00
|
|
|
async function findOrCreateThreadForUser(user) {
|
|
|
|
const existingThread = await findOpenThreadByUserId(user.id);
|
|
|
|
if (existingThread) return existingThread;
|
|
|
|
|
|
|
|
return createNewThreadForUser(user);
|
|
|
|
}
|
|
|
|
|
2018-03-11 15:32:14 -04:00
|
|
|
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)
|
2018-03-11 16:36:52 -04:00
|
|
|
.whereNotNull('scheduled_close_at')
|
2018-03-11 15:32:14 -04:00
|
|
|
.select();
|
|
|
|
|
|
|
|
return threads.map(thread => new Thread(thread));
|
|
|
|
}
|
|
|
|
|
2019-03-06 14:37:36 -05:00
|
|
|
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));
|
|
|
|
}
|
|
|
|
|
2017-12-31 19:16:05 -05:00
|
|
|
module.exports = {
|
2018-02-11 14:54:30 -05:00
|
|
|
findById,
|
2017-12-31 19:16:05 -05:00
|
|
|
findOpenThreadByUserId,
|
|
|
|
findByChannelId,
|
2018-02-11 14:54:30 -05:00
|
|
|
findOpenThreadByChannelId,
|
2018-03-11 16:27:52 -04:00
|
|
|
findSuspendedThreadByChannelId,
|
2017-12-31 19:16:05 -05:00
|
|
|
createNewThreadForUser,
|
|
|
|
getClosedThreadsByUserId,
|
2018-02-11 14:54:30 -05:00
|
|
|
findOrCreateThreadForUser,
|
2018-03-11 15:32:14 -04:00
|
|
|
getThreadsThatShouldBeClosed,
|
2019-03-06 14:37:36 -05:00
|
|
|
getThreadsThatShouldBeSuspended,
|
2018-02-11 14:54:30 -05:00
|
|
|
createThreadInDB
|
2017-12-24 15:04:08 -05:00
|
|
|
};
|