import { Guild , Snowflake , User , TextChannel , GuildMember , MessageEmbed , GuildChannel , } from 'discord.js' ; import mongoose , { Document } from 'mongoose' ; import { ObjectId } from 'mongodb' ; import { ClientService } from './client.service' ; import { GuildService } from './guild.service' ; import { LoggerService } from './logger.service' ; import { IMessage , Maybe } from '../common/types' ; import Constants from '../common/constants' ; import * as fs from 'fs' ; import { WarningService } from './warning.service' ; import { ModerationBanModel , ModerationReportModel , ModerationWarningModel , } from '../schemas/moderation.schema' ; export namespace Moderation { export namespace Helpers { export async function resolveUser ( guild , tag ) { try { const id = guild . members . cache . find ( ( gm ) => gm . user . tag === tag ) ?. user . id ; if ( id ) { return id ; } const bannedMembers = await guild . bans . fetch ( ) ; const bannedMember = bannedMembers . filter ( ( u ) => u . user . tag === tag ) . first ( ) ; if ( bannedMember ) { return bannedMember . user . id ; } if ( parseInt ( tag ) ) { return tag ; } return null ; } catch ( _ ) { return null ; } } export function serialiseReportForMessage ( report ) { const attachments = ( report . attachments && report . attachments . length && report . attachments . join ( ', ' ) ) || 'no attachment' ; return `\`${ report . description ?? 'no description' }\`: [${ attachments }] at ${ new Date ( report . timeStr ) . toLocaleString ( 'en-US' ) }` ; } } export interface IModerationReport { guild : Snowflake ; user : Snowflake ; description ? : string ; attachments ? : string [ ] ; timeStr : string ; _id ? : ObjectId ; } export type ModerationReportDocument = IModerationReport & Document ; export interface IModerationBan { user : Snowflake ; guild : Snowflake ; date : Date ; active : boolean ; reason : string ; reportId ? : ObjectId ; _id : ObjectId ; } export type ModerationBanDocument = IModerationBan & Document ; export interface IModerationWarning { user : Snowflake ; guild : Snowflake ; date : Date ; reportId ? : ObjectId ; _id : ObjectId ; } export interface IModReportRequest { subCommand : string ; givenHandle : string ; description : string ; } export type ModerationWarningDocument = IModerationWarning & Document ; export class Report implements IModerationReport { public guild : Snowflake ; public user : Snowflake ; public description ? : string ; public attachments ? : string [ ] ; public timeStr : string ; constructor ( guild , id , description ? , attachments ? ) { this . guild = guild . id ; this . user = id ; this . description = description ; this . attachments = attachments ; const has_desc = this . description && this . description . length ; const has_atta = this . attachments && this . attachments . length ; if ( ! has_desc && ! has_atta ) { throw new Error ( 'Need either a description or attachment(s).' ) ; } this . timeStr = new Date ( ) . toISOString ( ) ; } public toString ( ) { return Helpers . serialiseReportForMessage ( this ) ; } } } export class ModService { constructor ( private _clientService , private _guildService , private _loggerService , private _warningService ) { } private _QUICK_WARNS_THRESH : number = 3 ; private _QUICK_WARNS_TIMEFRAME : number = 14 ; private _KICK_THRESH : number = 3 ; private _SUSPEND_THRESH : number = 4 ; private _BAN_THRESH : number = 5 ; public async fileReport ( report ) { const res = await this . _insertReport ( report ) ; if ( res ) { return `Added report: ${ Moderation . Helpers . serialiseReportForMessage ( report ) }` ; } else { return 'Could not insert report.' ; } } public async fileAnonReportWithTicketId ( ticket_id , message ) { message . author = this . _clientService . user as User ; this . _loggerService . info ( `Filing report with ticket_id ${ ticket_id }` ) ; const userOffenseChan = this . _guildService . get ( ) . channels . cache . find ( ( c ) => c . name === Constants . Channels . Staff . ModCommands ) ; if ( ! userOffenseChan ) { this . _loggerService . error ( 'Could not file report for ' + message ) ; return undefined ; } await ( userOffenseChan as TextChannel ) . send ( { content : `:rotating_light::rotating_light: ANON REPORT Ticket ${ ticket_id } :rotating_light::rotating_light:\n ${ message . content }` , files : message . attachments . map ( ( a ) => a . url ) , } ) . catch ( ( e ) => this . _loggerService . error ( e ) ) ; return ticket_id ; } public fileAnonReport ( message ) { return this . fileAnonReportWithTicketId ( this . generateTicketId ( message ) , message ) ; } public async respondToAnonReport ( ticket_id , message ) { const decoded = this . _tryDecodeTicketId ( ticket_id ) ; if ( ! decoded ) { return undefined ; } const [ , user_id ] = decoded ; const user = this . _guildService . get ( ) . members . cache . get ( user_id ) ; if ( ! user ) { this . _loggerService . error ( `respondToAnonReport: Could not resolve ${ user_id } to a Guild member.` ) ; return undefined ; } await user . send ( { content : `Response to your anonymous report ticket ${ ticket_id }:\n ${ message . content }` , files : message . attachments . map ( ( a ) => a . url ) , } ) . catch ( ( e ) => this . _loggerService . error ( e ) ) ; return ticket_id ; } public generateTicketId ( message ) { return `${ message . id }x${ message . author ?. id }` ; } public isTicketId ( maybe_ticket_id ) { return ! ! this . _tryDecodeTicketId ( maybe_ticket_id ) ; } private _tryDecodeTicketId ( ticket_id ) { const _REPORT_ID = /([^x]+)x([0-9]+)/ ; const match_report_id = ticket_id . match ( _REPORT_ID ) ; if ( ! match_report_id ) { return undefined ; } const [ , message_id , user_id ] = match_report_id ; return [ message_id , user_id ] ; } public async fileWarning ( report ) { const member = await this . _guildService . get ( ) . members . fetch ( ) . then ( ( fetched ) => fetched . get ( report . user ) ) ; if ( member ?. user . bot ) { return 'You cannot warn a bot.' ; } const fileReportResult = await this . _insertReport ( report ) ; await ModerationWarningModel . create ( { user : report . user , guild : report . guild , date : new Date ( ) , reportId : fileReportResult , } ) ; await this . _warningService . sendModMessageToUser ( 'A warning has been issued. ' , report ) ; const warnings = ( await ModerationWarningModel . find ( { user : report . user , guild : report . guild } ) . sort ( { date : - 1 , } ) ) ?? [ ] ; const tempBanResult = await this . _checkQuickWarns ( warnings , report , fileReportResult ) ; if ( tempBanResult ) { return tempBanResult ; } const actionResult = await this . _checkNumberOfWarns ( warnings , report , fileReportResult ) ; return `User warned: ${ Moderation . Helpers . serialiseReportForMessage ( report ) }\n${ actionResult }` ; } private async _checkNumberOfWarns ( warnings , report , fileReportResult ) { const member = this . _guildService . get ( ) . members . cache . get ( report . user ) ; if ( ! member ) { return "Couldn't find user" ; } const numWarns = warnings . length ; if ( numWarns < this . _KICK_THRESH ) { return 'No further action was taken.' ; } if ( numWarns >= this . _KICK_THRESH && numWarns < this . _SUSPEND_THRESH ) { return this . _kickUser ( member ) ; } if ( numWarns >= this . _SUSPEND_THRESH && numWarns < this . _BAN_THRESH ) { return this . _suspendMember ( member ) ; } return ( 'User has been warned too many times. Escalating to permanent ban.\n' + `Result: ${ await this . _fileBan ( report , fileReportResult , true ) }` ) ; } private async _checkQuickWarns ( warns , report , fileReportResult ) { const recentWarnings = warns . slice ( 0 , this . _QUICK_WARNS_THRESH ) ; const beginningOfWarningRange = new Date ( ) ; const warningRange = this . _QUICK_WARNS_TIMEFRAME ; beginningOfWarningRange . setDate ( beginningOfWarningRange . getDate ( ) - warningRange ) ; const shouldTempBan = recentWarnings . length >= this . _QUICK_WARNS_THRESH && recentWarnings . reduce ( ( acc , x ) => acc && x . date >= beginningOfWarningRange , true ) ; if ( shouldTempBan ) { return ( `User has been warned too many times within ${ this . _QUICK_WARNS_TIMEFRAME } days. Escalating to temp ban.\n` + `Result: ${ await this . _fileBan ( report , fileReportResult , false ) }` ) ; } return false ; } public async fileBan ( report , isPermanent ) { const res = await this . _insertReport ( report ) ; return this . _fileBan ( report , res , isPermanent ) ; } private async _fileBan ( report , reportResult , isPermanent ) { if ( await this . _isUserCurrentlyBanned ( report . guild , report . user ) ) { return 'User is already banned.' ; } await ModerationBanModel . create ( { guild : report . guild , user : report . user , date : new Date ( ) , active : true , reason : report . description ?? '<none>' , reportId : reportResult , permanent : isPermanent , } ) ; try { await this . _guildService . get ( ) . members . cache . get ( report . user ) ?. send ( `You have been banned ${ isPermanent ? 'permanently' : 'for one week' } for ${ report . description ?? report . attachments ?. join ( ',' ) }` ) ; } catch ( e ) { this . _loggerService . warn ( `Error telling user is banned. ${ e }` ) ; } try { await this . _guildService . get ( ) . members . ban ( report . user , { reason : report . description } ) ; } catch ( e ) { return `Issue occurred trying to ban user. ${ e }` ; } return 'Banned User' ; } private async _kickUser ( member ) { await member . send ( 'You are being kicked for too many warnings\n' + `You currently have been warned ${ this . _KICK_THRESH } times.\n` + `After ${ this . _SUSPEND_THRESH } warnings, you will have restricted access to the server.\n` + `After ${ this . _BAN_THRESH } warnings, you will be banned permanently.` ) ; try { await member . kick ( ) ; } catch ( e ) { this . _loggerService . warn ( `Tried to kick user ${ member . user . username } but couldn't. e: ${ e }` ) ; return `Error kicking user: ${ e }` ; } return 'Kicked user' ; } private async _suspendMember ( member ) { try { await Promise . all ( member . roles . cache . filter ( ( r ) => r . name !== '@everyone' ) . map ( ( r ) => member . roles . remove ( r ) ) ) ; const suspendedRole = this . _guildService . getRole ( Constants . Roles . Suspended ) ; await member . roles . add ( suspendedRole ) ; return `User has crossed threshold of ${ this . _SUSPEND_THRESH }, suspending user.\n` ; } catch ( e ) { return `Error suspending user ${ e }` ; } } public async getModerationSummary ( guild , username ) { const id = await Moderation . Helpers . resolveUser ( guild , username ) ; if ( ! id ) { return 'No such user found.' ; } const reports = await ModerationReportModel . find ( { guild : guild . id , user : id } ) ; const warnings = await ModerationWarningModel . find ( { guild : guild . id , user : id } ) ; const banStatus = await this . _getBanStatus ( guild , id ) ; const mostRecentWarning = warnings . sort ( ( a , b ) => ( a . date > b . date ? - 1 : 1 ) ) ; let lastWarning = '<none>' ; if ( mostRecentWarning . length ) { const _id = mostRecentWarning [ 0 ] . reportId ; const rep = await ModerationReportModel . findOne ( { _id } ) ; if ( rep ) { lastWarning = Moderation . Helpers . serialiseReportForMessage ( rep ) ; } } const reply = new MessageEmbed ( ) ; reply . setTitle ( 'Moderation Summary on ' + username ) ; reply . addField ( 'Total Reports' , reports . length . toString ( ) ) ; reply . addField ( 'Total Warnings' , warnings . length . toString ( ) ) ; reply . addField ( 'Ban Status' , banStatus ) ; reply . addField ( 'Last warning' , lastWarning ) ; reply . setTimestamp ( new Date ( ) ) ; reply . setColor ( '#ff3300' ) ; return reply ; } public async getFullReport ( guild , user_handle ) { const id = await Moderation . Helpers . resolveUser ( guild , user_handle ) ; if ( ! id ) { throw new Error ( 'User not found' ) ; } const reports = await ModerationReportModel . find ( { guild : guild . id , user : id } ) ; const warnings = await ModerationWarningModel . find ( { guild : guild . id , user : id } ) ; const banStatus = await this . _getBanStatus ( guild , id ) ; if ( ! reports ) { throw new Error ( 'Couldnt get reports' ) ; } const rows = new Array ( reports . length ) ; reports . forEach ( ( report , i ) => { rows [ i ] = new Array ( 2 ) ; rows [ i ] [ 0 ] = this . _serializeReportForTable ( report ) ; const reportID = report . _id ?. toHexString ( ) ; if ( ! reportID || ! warnings ) { return ; } const relatedWarn = warnings . filter ( ( w ) => w . reportId ?. toHexString ( ) === reportID ) ; if ( ! relatedWarn ?. length ) { return ; } rows [ i ] [ 1 ] = this . _serializeWarningForTable ( relatedWarn [ 0 ] ) ; } ) ; const table = this . _createTableFromReports ( rows ) ; const defaultHTML = fs . readFileSync ( './src/app/__generated__/reportTemplate.html' , 'utf8' ) ; const data = defaultHTML . replace ( 'BAN_STATUS' , banStatus ) . replace ( 'DYNAMIC_TABLE' , table ) . replace ( 'NUM_REPORTS' , reports . length + '' ) . replace ( 'NUM_WARNS' , warnings ?. length + '' || '0' ) . replace ( 'USER_NAME' , user_handle ) ; return await this . _writeDataToFile ( data ) ; } private async _getBanStatus ( guild , id ) { const mostRecentBan = ( await ModerationBanModel . find ( { guild : guild . id , user : id } ) . sort ( { date : - 1 } ) . limit ( 1 ) ) ?? [ ] ; if ( mostRecentBan . length && mostRecentBan [ 0 ] . active ) { return `Banned since ${ mostRecentBan [ 0 ] . date . toLocaleString ( ) }` ; } return 'Not banned' ; } private _createTableFromReports ( rows ) { return rows . map ( ( row ) => `<tr>${ row . map ( ( cell ) => `<td>${ cell }</td>` ) . join ( '\n' ) }</tr>` ) . join ( '\n' ) ; } private _serializeReportForTable ( report ) { const serializedReport = `Reported on: ${ report . timeStr }<br />Description: ${ report . description ?? 'No Description' }` ; if ( ! report . attachments ?. length ) { return serializedReport ; } return `${ serializedReport }<br />Attachments: ${ report . attachments . map ( ( a ) => { if ( a . includes ( '.png' ) || a . includes ( '.jpg' ) ) { return `<img src="${ a }">` ; } return `<a href="${ a }">Linked File</a>` ; } ) }` ; } private _serializeWarningForTable ( warning ) { return `Warned on ${ warning . date }` ; } private async _writeDataToFile ( data ) { const discrim = '' + Math . random ( ) ; const filename = `/tmp/report${ discrim }.html` ; await fs . promises . writeFile ( filename , data ) . catch ( ( err ) => { this . _loggerService . error ( 'While writing to ' + filename , err ) ; } ) ; return filename ; } public async checkForScheduledUnBans ( ) { this . _loggerService . info ( 'Running UnBan' ) ; if ( ! mongoose . connection . readyState ) { this . _loggerService . info ( 'No modbans DB. Skipping this run of checkForScheduledUnBans' ) ; return ; } const guild = this . _guildService . get ( ) ; const bulk = ModerationBanModel . collection . initializeUnorderedBulkOp ( ) ; const sevenDaysAgo = new Date ( ) ; sevenDaysAgo . setDate ( sevenDaysAgo . getDate ( ) - 7 ) ; try { const toUnban = await ModerationBanModel . find ( { guild : guild . id , active : true , date : { $lte : new Date ( sevenDaysAgo . toISOString ( ) ) } , } ) ; const unbans = toUnban . map ( async ( ban ) => { this . _loggerService . info ( 'Unbanning user ' + ban . user ) ; try { await guild . members . unban ( ban . user ) ; } catch ( e ) { this . _loggerService . error ( 'Failed to unban user ' + ban . user , e ) ; } bulk . find ( { _id : ban . _id } ) . updateOne ( { $set : { active : false } } ) ; } ) ; await Promise . all ( unbans ) ; if ( unbans . length === 0 ) { this . _loggerService . info ( 'No UnBans to perform.' ) ; return ; } await bulk . execute ( ) ; } catch ( e ) { this . _loggerService . error ( e ) ; } } public async channelBan ( guild , username , channels ) { const id = await Moderation . Helpers . resolveUser ( guild , username ) ; const successfulBanChannelList = [ ] ; if ( ! id ) { this . _loggerService . error ( `Failed to resolve ${ username } to a user.` ) ; return successfulBanChannelList ; } const user = guild . members . cache . get ( id ) ?. user ; if ( ! user ) { this . _loggerService . error ( `Failed to resolve ${ username } to a user.` ) ; return successfulBanChannelList ; } const channelBanPromises = channels . reduce ( ( acc , channel ) => { this . _loggerService . debug ( `Taking channel permissions away in ${ channel . name }` ) ; acc . push ( channel . permissionOverwrites . create ( id , { VIEW_CHANNEL : false , SEND_MESSAGES : false , } ) . then ( ( ) => successfulBanChannelList . push ( channel ) ) . catch ( ( ex ) => { this . _loggerService . error ( `Failed to adjust permissions for ${ username } in ${ channel . name }` , ex ) ; } ) ) ; return acc ; } , [ ] as Promise < void | number > [ ] ) ; await Promise . all ( channelBanPromises ) ; try { await this . _insertReport ( new Moderation . Report ( guild , id , `Took channel permissions away in ${ successfulBanChannelList . map ( ( c ) => c . name ) . join ( ', ' ) }` ) ) ; } catch ( ex ) { this . _loggerService . error ( 'Failed to add report about channel ban.' , ex ) ; } return successfulBanChannelList ; } private async _insertReport ( report ) { const rep = await ModerationReportModel . create ( report ) ; return rep ?. id ; } private async _isUserCurrentlyBanned ( guild , user ) { const userBan = await ModerationBanModel . findOne ( { guild , user , active : true } ) ; return userBan ?. active ; } }