2020-08-12 17:08:37 -04:00
const { User , Member } = require ( "eris" ) ;
2019-04-15 12:06:59 -04:00
2020-08-12 17:08:37 -04:00
const transliterate = require ( "transliteration" ) ;
const moment = require ( "moment" ) ;
const uuid = require ( "uuid" ) ;
const humanizeDuration = require ( "humanize-duration" ) ;
2017-12-24 15:04:08 -05:00
2020-08-12 17:08:37 -04:00
const bot = require ( "../bot" ) ;
const knex = require ( "../knex" ) ;
const config = require ( "../cfg" ) ;
const utils = require ( "../utils" ) ;
const updates = require ( "./updates" ) ;
2017-12-24 15:04:08 -05:00
2020-08-12 17:08:37 -04:00
const Thread = require ( "./Thread" ) ;
2020-07-15 16:50:30 -04:00
const { callBeforeNewThreadHooks } = require ( "../hooks/beforeNewThread" ) ;
2020-08-12 17:08:37 -04:00
const { THREAD _STATUS , DISOCRD _CHANNEL _TYPES } = 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 ) {
2020-08-12 17:08:37 -04:00
const thread = await knex ( "threads" )
. where ( "id" , id )
2018-02-11 14:54:30 -05:00
. 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 ) {
2020-08-12 17:08:37 -04:00
const thread = await knex ( "threads" )
. where ( "user_id" , userId )
. 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 } )
} ;
}
2020-07-15 16:50:30 -04:00
/ * *
* @ typedef CreateNewThreadForUserOpts
* @ property { boolean } quiet If true , doesn ' t ping mentionRole or reply with responseMessage
* @ property { boolean } ignoreRequirements If true , creates a new thread even if the account doesn ' t meet requiredAccountAge
* @ property { string } source A string identifying the source of the new thread
* /
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
2020-07-15 16:50:30 -04:00
* @ param { CreateNewThreadForUserOpts } opts
2019-04-15 12:06:59 -04:00
* @ returns { Promise < Thread | undefined > }
2017-12-31 19:16:05 -05:00
* @ throws { Error }
* /
2020-07-13 17:13:32 -04:00
async function createNewThreadForUser ( user , opts = { } ) {
const quiet = opts . quiet != null ? opts . quiet : false ;
const ignoreRequirements = opts . ignoreRequirements != null ? opts . ignoreRequirements : false ;
2017-12-31 19:16:05 -05:00
const existingThread = await findOpenThreadByUserId ( user . id ) ;
if ( existingThread ) {
2020-08-12 17:08:37 -04:00
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 ;
}
}
2020-08-12 16:19:11 -04:00
// Call any registered beforeNewThreadHooks
const hookResult = await callBeforeNewThreadHooks ( { user , opts } ) ;
if ( hookResult . cancelled ) return ;
2019-04-15 12:06:59 -04: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 ) ;
2020-08-12 17:08:37 -04:00
if ( cleanName === "" ) cleanName = "unknown" ;
2019-04-15 12:06:59 -04:00
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
2020-07-15 16:50:30 -04:00
let newThreadCategoryId = hookResult . categoryId || null ;
2019-03-27 22:54:12 -04:00
2020-07-13 17:13:32 -04:00
if ( ! newThreadCategoryId && config . categoryAutomation . newThreadFromGuild ) {
2019-03-27 22:54:12 -04:00
// 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 {
2020-08-12 16:19:11 -04:00
createdChannel = await utils . getInboxGuild ( ) . createChannel ( channelName , DISOCRD _CHANNEL _TYPES . GUILD _TEXT , {
2020-08-12 17:08:37 -04:00
reason : "New Modmail thread" ,
2020-08-12 16:19:11 -04:00
parentID : 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 ,
2020-08-12 17:08:37 -04:00
created _at : moment . utc ( ) . format ( "YYYY-MM-DD HH:mm:ss" )
2017-12-24 15:04:08 -05:00
} ) ;
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-05-25 04:52:31 -04:00
await newThread . sendSystemMessageToUser ( 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
2020-08-12 17:08:37 -04:00
let infoHeader = infoHeaderItems . join ( ", " ) ;
2018-04-21 08:38:21 -04:00
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 ) ;
2020-08-12 17:08:37 -04:00
headerItems . push ( ` ROLES ** ${ roles . map ( r => r . name ) . join ( ", " ) } ** ` ) ;
2019-06-09 09:04:17 -04:00
}
2020-08-12 17:08:37 -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. ` ;
}
2020-08-12 17:08:37 -04:00
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 ( ) ;
2020-08-12 17:08:37 -04:00
const now = moment . utc ( ) . format ( "YYYY-MM-DD HH:mm:ss" ) ;
2017-12-24 15:04:08 -05:00
const finalData = Object . assign ( { created _at : now , is _legacy : 0 } , data , { id : threadId } ) ;
2020-08-12 17:08:37 -04: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 ) {
2020-08-12 17:08:37 -04:00
const thread = await knex ( "threads" )
. where ( "channel_id" , channelId )
2017-12-24 15:04:08 -05:00
. 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 ) {
2020-08-12 17:08:37 -04:00
const thread = await knex ( "threads" )
. where ( "channel_id" , channelId )
. where ( "status" , THREAD _STATUS . OPEN )
2018-02-11 14:54:30 -05:00
. 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 ) {
2020-08-12 17:08:37 -04:00
const thread = await knex ( "threads" )
. where ( "channel_id" , channelId )
. where ( "status" , THREAD _STATUS . SUSPENDED )
2018-03-11 16:27:52 -04:00
. 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 ) {
2020-08-12 17:08:37 -04:00
const threads = await knex ( "threads" )
. where ( "status" , THREAD _STATUS . CLOSED )
. where ( "user_id" , userId )
2017-12-31 19:16:05 -05:00
. 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 ) {
2020-08-12 17:08:37 -04:00
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 ) ;
}
2020-07-15 16:50:30 -04:00
/ * *
* @ param { User } user
* @ param { CreateNewThreadForUserOpts } opts
* @ returns { Promise < Thread | undefined > }
* /
async function findOrCreateThreadForUser ( user , opts = { } ) {
2018-02-11 14:54:30 -05:00
const existingThread = await findOpenThreadByUserId ( user . id ) ;
if ( existingThread ) return existingThread ;
2020-07-15 16:50:30 -04:00
return createNewThreadForUser ( user , opts ) ;
2018-02-11 14:54:30 -05:00
}
2018-03-11 15:32:14 -04:00
async function getThreadsThatShouldBeClosed ( ) {
2020-08-12 17:08:37 -04:00
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 )
. 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 ( ) {
2020-08-12 17:08:37 -04:00
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" )
2019-03-06 14:37:36 -05:00
. 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
} ;