2021-02-13 17:29:10 +02:00
import { BaseGuildRepository } from "./BaseGuildRepository" ;
2021-04-02 16:36:45 +03:00
import { FindConditions , getRepository , In , IsNull , LessThan , Not , Repository } from "typeorm" ;
2021-02-13 17:29:10 +02:00
import { Counter } from "./entities/Counter" ;
import { CounterValue } from "./entities/CounterValue" ;
2021-04-02 16:36:45 +03:00
import {
CounterTrigger ,
isValidCounterComparisonOp ,
TRIGGER_COMPARISON_OPS ,
TriggerComparisonOp ,
} from "./entities/CounterTrigger" ;
2021-02-13 17:29:10 +02:00
import { CounterTriggerState } from "./entities/CounterTriggerState" ;
import moment from "moment-timezone" ;
import { DAYS , DBDateFormat , HOURS , MINUTES } from "../utils" ;
import { connection } from "./db" ;
2021-04-14 00:19:39 +03:00
import { Queue } from "../Queue" ;
2021-02-13 17:29:10 +02:00
const DELETE_UNUSED_COUNTERS_AFTER = 1 * DAYS ;
const DELETE_UNUSED_COUNTER_TRIGGERS_AFTER = 1 * DAYS ;
const MAX_COUNTER_VALUE = 2147483647 ; // 2^31-1, for MySQL INT
2021-04-14 00:19:39 +03:00
const decayQueue = new Queue ( ) ;
2021-02-13 17:29:10 +02:00
async function deleteCountersMarkedToBeDeleted ( ) : Promise < void > {
await getRepository ( Counter )
. createQueryBuilder ( )
. where ( "delete_at <= NOW()" )
. delete ( )
. execute ( ) ;
}
async function deleteTriggersMarkedToBeDeleted ( ) : Promise < void > {
await getRepository ( CounterTrigger )
. createQueryBuilder ( )
. where ( "delete_at <= NOW()" )
. delete ( )
. execute ( ) ;
}
setInterval ( deleteCountersMarkedToBeDeleted , 1 * HOURS ) ;
setInterval ( deleteTriggersMarkedToBeDeleted , 1 * HOURS ) ;
setTimeout ( deleteCountersMarkedToBeDeleted , 1 * MINUTES ) ;
setTimeout ( deleteTriggersMarkedToBeDeleted , 1 * MINUTES ) ;
export class GuildCounters extends BaseGuildRepository {
private counters : Repository < Counter > ;
private counterValues : Repository < CounterValue > ;
private counterTriggers : Repository < CounterTrigger > ;
private counterTriggerStates : Repository < CounterTriggerState > ;
constructor ( guildId ) {
super ( guildId ) ;
this . counters = getRepository ( Counter ) ;
this . counterValues = getRepository ( CounterValue ) ;
this . counterTriggers = getRepository ( CounterTrigger ) ;
this . counterTriggerStates = getRepository ( CounterTriggerState ) ;
}
async findOrCreateCounter ( name : string , perChannel : boolean , perUser : boolean ) : Promise < Counter > {
const existing = await this . counters . findOne ( {
where : {
guild_id : this.guildId ,
name ,
} ,
} ) ;
if ( existing ) {
// If the existing counter's properties match the ones we're looking for, return it.
// Otherwise, delete the existing counter and re-create it with the proper properties.
if ( existing . per_channel === perChannel && existing . per_user === perUser ) {
2021-04-02 16:36:45 +03:00
await this . counters . update ( { id : existing.id } , { delete_at : null } ) ;
2021-02-13 17:29:10 +02:00
return existing ;
}
await this . counters . delete ( { id : existing.id } ) ;
}
const insertResult = await this . counters . insert ( {
guild_id : this.guildId ,
name ,
per_channel : perChannel ,
per_user : perUser ,
last_decay_at : moment.utc ( ) . format ( DBDateFormat ) ,
} ) ;
return ( await this . counters . findOne ( {
where : {
id : insertResult.identifiers [ 0 ] . id ,
} ,
} ) ) ! ;
}
async markUnusedCountersToBeDeleted ( idsToKeep : number [ ] ) : Promise < void > {
2021-04-02 16:36:45 +03:00
const criteria : FindConditions < Counter > = {
guild_id : this.guildId ,
delete_at : IsNull ( ) ,
} ;
if ( idsToKeep . length ) {
criteria . id = Not ( In ( idsToKeep ) ) ;
2021-02-13 17:29:10 +02:00
}
const deleteAt = moment
. utc ( )
. add ( DELETE_UNUSED_COUNTERS_AFTER , "ms" )
. format ( DBDateFormat ) ;
2021-04-02 16:36:45 +03:00
await this . counters . update ( criteria , {
delete_at : deleteAt ,
} ) ;
2021-02-13 17:29:10 +02:00
}
async deleteCountersMarkedToBeDeleted ( ) : Promise < void > {
await this . counters
. createQueryBuilder ( )
. where ( "delete_at <= NOW()" )
. delete ( )
. execute ( ) ;
}
async changeCounterValue ( id : number , channelId : string | null , userId : string | null , change : number ) : Promise < void > {
if ( typeof change !== "number" || Number . isNaN ( change ) || ! Number . isFinite ( change ) ) {
throw new Error ( ` changeCounterValue() change argument must be a number ` ) ;
}
channelId = channelId || "0" ;
userId = userId || "0" ;
const rawUpdate =
change >= 0 ? ` value = LEAST(value + ${ change } , ${ MAX_COUNTER_VALUE } ) ` : ` value = GREATEST(value ${ change } , 0) ` ;
await this . counterValues . query (
`
INSERT INTO counter_values ( counter_id , channel_id , user_id , value )
VALUES ( ? , ? , ? , ? )
ON DUPLICATE KEY UPDATE $ { rawUpdate }
` ,
[ id , channelId , userId , Math . max ( change , 0 ) ] ,
) ;
}
async setCounterValue ( id : number , channelId : string | null , userId : string | null , value : number ) : Promise < void > {
if ( typeof value !== "number" || Number . isNaN ( value ) || ! Number . isFinite ( value ) ) {
throw new Error ( ` setCounterValue() value argument must be a number ` ) ;
}
channelId = channelId || "0" ;
userId = userId || "0" ;
value = Math . max ( value , 0 ) ;
await this . counterValues . query (
`
INSERT INTO counter_values ( counter_id , channel_id , user_id , value )
VALUES ( ? , ? , ? , ? )
ON DUPLICATE KEY UPDATE value = ?
` ,
[ id , channelId , userId , value , value ] ,
) ;
}
2021-04-14 00:19:39 +03:00
decay ( id : number , decayPeriodMs : number , decayAmount : number ) {
return decayQueue . add ( async ( ) = > {
const counter = ( await this . counters . findOne ( {
where : {
id ,
} ,
} ) ) ! ;
const diffFromLastDecayMs = moment . utc ( ) . diff ( moment . utc ( counter . last_decay_at ! ) , "ms" ) ;
if ( diffFromLastDecayMs < decayPeriodMs ) {
return ;
}
2021-02-13 17:29:10 +02:00
2021-04-14 00:19:39 +03:00
const decayAmountToApply = Math . round ( ( diffFromLastDecayMs / decayPeriodMs ) * decayAmount ) ;
if ( decayAmountToApply === 0 ) {
return ;
}
2021-02-13 17:29:10 +02:00
2021-04-14 00:19:39 +03:00
// Calculate new last_decay_at based on the rounded decay amount we applied. This makes it so that over time, the decayed amount will stay accurate, even if we round some here.
const newLastDecayDate = moment
. utc ( counter . last_decay_at )
. add ( ( decayAmountToApply / decayAmount ) * decayPeriodMs , "ms" )
. format ( DBDateFormat ) ;
const rawUpdate =
decayAmountToApply >= 0
? ` GREATEST(value - ${ decayAmountToApply } , 0) `
: ` LEAST(value + ${ Math . abs ( decayAmountToApply ) } , ${ MAX_COUNTER_VALUE } ) ` ;
// Using an UPDATE with ORDER BY in an attempt to avoid deadlocks from simultaneous decays
// Also see https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlocks-handling.html
await this . counterValues
. createQueryBuilder ( "CounterValue" )
. where ( "counter_id = :id" , { id } )
. orderBy ( "id" )
. update ( {
value : ( ) = > rawUpdate ,
} )
. execute ( ) ;
await this . counters . update (
{
id ,
} ,
{
last_decay_at : newLastDecayDate ,
} ,
) ;
} ) ;
2021-02-13 17:29:10 +02:00
}
2021-04-02 16:36:45 +03:00
async markUnusedTriggersToBeDeleted ( triggerIdsToKeep : number [ ] ) {
let triggersToMarkQuery = this . counterTriggers
. createQueryBuilder ( "counterTriggers" )
. innerJoin ( Counter , "counters" , "counters.id = counterTriggers.counter_id" )
. where ( "counters.guild_id = :guildId" , { guildId : this.guildId } ) ;
// If there are no active triggers, we just mark all triggers from the guild to be deleted.
// Otherwise, we mark all but the active triggers in the guild.
if ( triggerIdsToKeep . length ) {
triggersToMarkQuery = triggersToMarkQuery . andWhere ( "counterTriggers.id NOT IN (:...triggerIds)" , {
triggerIds : triggerIdsToKeep ,
} ) ;
}
const triggersToMark = await triggersToMarkQuery . getMany ( ) ;
if ( triggersToMark . length ) {
const deleteAt = moment
. utc ( )
. add ( DELETE_UNUSED_COUNTER_TRIGGERS_AFTER , "ms" )
. format ( DBDateFormat ) ;
await this . counterTriggers . update (
{
id : In ( triggersToMark . map ( t = > t . id ) ) ,
} ,
{
delete_at : deleteAt ,
} ,
) ;
}
2021-02-13 17:29:10 +02:00
}
async deleteTriggersMarkedToBeDeleted ( ) : Promise < void > {
await this . counterTriggers
. createQueryBuilder ( )
. where ( "delete_at <= NOW()" )
. delete ( )
. execute ( ) ;
}
async initCounterTrigger (
counterId : number ,
2021-04-02 16:36:45 +03:00
triggerName : string ,
2021-02-13 17:29:10 +02:00
comparisonOp : TriggerComparisonOp ,
comparisonValue : number ,
2021-04-02 16:36:45 +03:00
reverseComparisonOp : TriggerComparisonOp ,
reverseComparisonValue : number ,
2021-02-13 17:29:10 +02:00
) : Promise < CounterTrigger > {
2021-04-02 16:36:45 +03:00
if ( ! isValidCounterComparisonOp ( comparisonOp ) ) {
2021-02-13 17:29:10 +02:00
throw new Error ( ` Invalid comparison op: ${ comparisonOp } ` ) ;
}
2021-04-02 16:36:45 +03:00
if ( ! isValidCounterComparisonOp ( reverseComparisonOp ) ) {
throw new Error ( ` Invalid comparison op: ${ reverseComparisonOp } ` ) ;
}
2021-02-13 17:29:10 +02:00
if ( typeof comparisonValue !== "number" ) {
throw new Error ( ` Invalid comparison value: ${ comparisonValue } ` ) ;
}
2021-04-02 16:36:45 +03:00
if ( typeof reverseComparisonValue !== "number" ) {
throw new Error ( ` Invalid comparison value: ${ reverseComparisonValue } ` ) ;
}
2021-02-13 17:29:10 +02:00
return connection . transaction ( async entityManager = > {
const existing = await entityManager . findOne ( CounterTrigger , {
counter_id : counterId ,
2021-04-02 16:36:45 +03:00
name : triggerName ,
2021-02-13 17:29:10 +02:00
} ) ;
if ( existing ) {
// Since all existing triggers are marked as to-be-deleted before they are re-initialized, this needs to be reset
2021-04-02 16:36:45 +03:00
await entityManager . update ( CounterTrigger , existing . id , {
comparison_op : comparisonOp ,
comparison_value : comparisonValue ,
reverse_comparison_op : reverseComparisonOp ,
reverse_comparison_value : reverseComparisonValue ,
delete_at : null ,
} ) ;
2021-02-13 17:29:10 +02:00
return existing ;
}
const insertResult = await entityManager . insert ( CounterTrigger , {
counter_id : counterId ,
2021-04-02 16:36:45 +03:00
name : triggerName ,
2021-02-13 17:29:10 +02:00
comparison_op : comparisonOp ,
comparison_value : comparisonValue ,
2021-04-02 16:36:45 +03:00
reverse_comparison_op : reverseComparisonOp ,
reverse_comparison_value : reverseComparisonValue ,
2021-02-13 17:29:10 +02:00
} ) ;
return ( await entityManager . findOne ( CounterTrigger , insertResult . identifiers [ 0 ] . id ) ) ! ;
} ) ;
}
/ * *
* Checks if a counter value with the given parameters triggers the specified comparison for the specified counter .
* If it does , mark this comparison for these parameters as triggered .
* Note that if this comparison for these parameters was already triggered previously , this function will return false .
* This means that a specific comparison for the specific parameters specified will only trigger * once * until the reverse trigger is triggered .
*
* @param counterId
* @param comparisonOp
* @param comparisonValue
* @param userId
* @param channelId
* @return Whether the given parameters newly triggered the given comparison
* /
async checkForTrigger (
counterTrigger : CounterTrigger ,
channelId : string | null ,
userId : string | null ,
) : Promise < boolean > {
channelId = channelId || "0" ;
userId = userId || "0" ;
return connection . transaction ( async entityManager = > {
const previouslyTriggered = await entityManager . findOne ( CounterTriggerState , {
trigger_id : counterTrigger.id ,
user_id : userId ! ,
channel_id : channelId ! ,
} ) ;
if ( previouslyTriggered ) {
return false ;
}
const matchingValue = await entityManager
. createQueryBuilder ( CounterValue , "cv" )
. leftJoin (
CounterTriggerState ,
"triggerStates" ,
"triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id" ,
{ triggerId : counterTrigger.id } ,
)
. where ( ` cv.value ${ counterTrigger . comparison_op } :value ` , { value : counterTrigger.comparison_value } )
2021-02-14 17:12:47 +02:00
. andWhere ( ` cv.counter_id = :counterId ` , { counterId : counterTrigger.counter_id } )
2021-02-13 17:29:10 +02:00
. andWhere ( "cv.channel_id = :channelId AND cv.user_id = :userId" , { channelId , userId } )
. andWhere ( "triggerStates.id IS NULL" )
. getOne ( ) ;
if ( matchingValue ) {
await entityManager . insert ( CounterTriggerState , {
trigger_id : counterTrigger.id ,
user_id : userId ! ,
channel_id : channelId ! ,
} ) ;
return true ;
}
return false ;
} ) ;
}
/ * *
* Checks if any counter values of the specified counter match the specified comparison .
* Like checkForTrigger ( ) , this can only happen * once * per unique counter value parameters until the reverse trigger is triggered for those values .
*
* @return Counter value parameters that triggered the condition
* /
async checkAllValuesForTrigger (
counterTrigger : CounterTrigger ,
) : Promise < Array < { channelId : string ; userId : string } > > {
return connection . transaction ( async entityManager = > {
const matchingValues = await entityManager
. createQueryBuilder ( CounterValue , "cv" )
. leftJoin (
CounterTriggerState ,
"triggerStates" ,
"triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id" ,
{ triggerId : counterTrigger.id } ,
)
. where ( ` cv.value ${ counterTrigger . comparison_op } :value ` , { value : counterTrigger.comparison_value } )
2021-02-14 17:12:47 +02:00
. andWhere ( ` cv.counter_id = :counterId ` , { counterId : counterTrigger.counter_id } )
2021-02-13 17:29:10 +02:00
. andWhere ( "triggerStates.id IS NULL" )
. getMany ( ) ;
if ( matchingValues . length ) {
await entityManager . insert (
CounterTriggerState ,
matchingValues . map ( row = > ( {
trigger_id : counterTrigger.id ,
2021-04-02 16:36:45 +03:00
channel_id : row.channel_id ,
user_id : row.user_id ,
2021-02-13 17:29:10 +02:00
} ) ) ,
) ;
}
return matchingValues . map ( row = > ( {
channelId : row.channel_id ,
userId : row.user_id ,
} ) ) ;
} ) ;
}
/ * *
* Checks if a counter value with the given parameters * no longer * matches the specified comparison , and thus triggers a "reverse trigger" .
* Like checkForTrigger ( ) , this can only happen * once * until the comparison is triggered normally again .
*
* @param counterId
* @param comparisonOp
* @param comparisonValue
* @param userId
* @param channelId
* @return Whether the given parameters triggered a reverse trigger for the given comparison
* /
async checkForReverseTrigger (
counterTrigger : CounterTrigger ,
channelId : string | null ,
userId : string | null ,
) : Promise < boolean > {
channelId = channelId || "0" ;
userId = userId || "0" ;
return connection . transaction ( async entityManager = > {
const matchingValue = await entityManager
. createQueryBuilder ( CounterValue , "cv" )
. innerJoin (
CounterTriggerState ,
"triggerStates" ,
"triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id" ,
{ triggerId : counterTrigger.id } ,
)
2021-04-02 16:36:45 +03:00
. where ( ` cv.value ${ counterTrigger . reverse_comparison_op } :value ` , {
value : counterTrigger.reverse_comparison_value ,
} )
2021-02-14 17:12:47 +02:00
. andWhere ( ` cv.counter_id = :counterId ` , { counterId : counterTrigger.counter_id } )
. andWhere ( ` cv.channel_id = :channelId AND cv.user_id = :userId ` , { channelId , userId } )
2021-02-13 17:29:10 +02:00
. getOne ( ) ;
if ( matchingValue ) {
await entityManager . delete ( CounterTriggerState , {
trigger_id : counterTrigger.id ,
user_id : userId ! ,
channel_id : channelId ! ,
} ) ;
return true ;
}
return false ;
} ) ;
}
/ * *
* Checks if any counter values of the specified counter * no longer * match the specified comparison , and thus triggers a "reverse trigger" for those values .
* Like checkForTrigger ( ) , this can only happen * once * per unique counter value parameters until the comparison is triggered normally again .
*
* @return Counter value parameters that triggered a reverse trigger
* /
async checkAllValuesForReverseTrigger (
counterTrigger : CounterTrigger ,
) : Promise < Array < { channelId : string ; userId : string } > > {
return connection . transaction ( async entityManager = > {
const matchingValues : Array < {
id : string ;
triggerStateId : string ;
user_id : string ;
channel_id : string ;
} > = await entityManager
. createQueryBuilder ( CounterValue , "cv" )
. innerJoin (
CounterTriggerState ,
"triggerStates" ,
"triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id" ,
{ triggerId : counterTrigger.id } ,
)
2021-04-02 16:36:45 +03:00
. where ( ` cv.value ${ counterTrigger . reverse_comparison_op } :value ` , {
value : counterTrigger.reverse_comparison_value ,
} )
2021-02-14 17:12:47 +02:00
. andWhere ( ` cv.counter_id = :counterId ` , { counterId : counterTrigger.counter_id } )
2021-02-13 17:29:10 +02:00
. select ( [
"cv.id AS id" ,
"cv.user_id AS user_id" ,
"cv.channel_id AS channel_id" ,
"triggerStates.id AS triggerStateId" ,
] )
. getRawMany ( ) ;
if ( matchingValues . length ) {
await entityManager . delete ( CounterTriggerState , {
id : In ( matchingValues . map ( v = > v . triggerStateId ) ) ,
} ) ;
}
return matchingValues . map ( row = > ( {
channelId : row.channel_id ,
userId : row.user_id ,
} ) ) ;
} ) ;
}
2021-02-13 22:08:38 +02:00
async getCurrentValue (
counterId : number ,
channelId : string | null ,
userId : string | null ,
) : Promise < number | undefined > {
const value = await this . counterValues . findOne ( {
where : {
counter_id : counterId ,
channel_id : channelId || "0" ,
user_id : userId || "0" ,
} ,
} ) ;
return value ? . value ;
}
2021-02-13 17:29:10 +02:00
}