2019-04-15 12:06:59 -04:00
const { User , Member } = require ( 'eris' ) ;
2017-12-24 15:04:08 -05:00
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' ) ;
2019-06-09 10:31:17 -04:00
const updates = require ( './updates' ) ;
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
2019-04-15 12:06:59 -04:00
const MINUTES = 60 * 1000 ;
const HOURS = 60 * MINUTES ;
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
2019-04-15 12:06:59 -04:00
* @ param { User } user
* @ param { Member } member
2018-04-07 19:56:30 -04:00
* @ param { Boolean } quiet If true , doesn ' t ping mentionRole or reply with responseMessage
2019-04-15 12:06:59 -04:00
* @ returns { Promise < Thread | undefined > }
2017-12-31 19:16:05 -05:00
* @ throws { Error }
* /
2019-04-15 12:26:14 -04:00
async function createNewThreadForUser ( user , quiet = false , ignoreRequirements = 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 12:06:59 -04:00
// 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
2019-04-15 12:26:14 -04:00
if ( config . requiredAccountAge && ! ignoreRequirements ) {
2019-04-15 12:06:59 -04:00
if ( user . createdAt > moment ( ) - config . requiredAccountAge * HOURS ) {
2018-07-27 12:48:45 -04:00
if ( config . accountAgeDeniedMessage ) {
2020-01-19 14:25:10 -05:00
const accountAgeDeniedMessage = utils . readMultilineConfigValue ( config . accountAgeDeniedMessage ) ;
2018-07-10 05:59:29 -04:00
const privateChannel = await user . getDMChannel ( ) ;
2020-01-19 14:25:10 -05:00
await privateChannel . createMessage ( 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
}
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 } ) ;
}
}
2019-04-15 12:06:59 -04:00
// 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
2019-04-15 12:26:14 -04:00
if ( config . requiredTimeOnServer && ! ignoreRequirements ) {
2019-04-15 12:06:59 -04:00
// 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 ) {
2020-01-19 14:25:10 -05:00
const timeOnServerDeniedMessage = utils . readMultilineConfigValue ( config . timeOnServerDeniedMessage ) ;
2019-04-15 12:06:59 -04:00
const privateChannel = await user . getDMChannel ( ) ;
2020-01-19 14:25:10 -05:00
await privateChannel . createMessage ( timeOnServerDeniedMessage ) ;
2019-04-15 12:06:59 -04:00
}
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 } ` ) ;
2019-03-27 22:54:12 -04:00
// 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-12-02 19:47:46 -05: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 ) {
2020-01-19 14:25:10 -05:00
const responseMessage = utils . readMultilineConfigValue ( config . responseMessage ) ;
2018-05-03 13:26:12 -04:00
try {
2020-01-19 14:25:10 -05:00
await newThread . _sendDMToUser ( responseMessage ) ;
2018-05-03 13:26:12 -04:00
} 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
}
}
2019-06-09 09:04:17 -04:00
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 ( ', ' ) } ** ` ) ;
}
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-12-02 19:47:46 -05: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 \n This 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 ) ;
2019-06-09 10:31:17 -04:00
if ( config . updateNotifications ) {
const availableUpdate = await updates . getAvailableUpdate ( ) ;
if ( availableUpdate ) {
await newThread . postNonLogMessage ( ` 📣 New bot version available ( ${ availableUpdate } ) ` ) ;
}
}
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
} ;