import moment from "moment-timezone"; import { FindOptionsWhere, In, IsNull, Not, Repository } from "typeorm"; import { Queue } from "../Queue.js"; import { DAYS, DBDateFormat, HOURS, MINUTES } from "../utils.js"; import { BaseGuildRepository } from "./BaseGuildRepository.js"; import { dataSource } from "./dataSource.js"; import { Counter } from "./entities/Counter.js"; import { CounterTrigger, TriggerComparisonOp, isValidCounterComparisonOp } from "./entities/CounterTrigger.js"; import { CounterTriggerState } from "./entities/CounterTriggerState.js"; import { CounterValue } from "./entities/CounterValue.js"; const DELETE_UNUSED_COUNTERS_AFTER = 1 * DAYS; const DELETE_UNUSED_COUNTER_TRIGGERS_AFTER = 1 * DAYS; export const MIN_COUNTER_VALUE = 0; export const MAX_COUNTER_VALUE = 2147483647; // 2^31-1, for MySQL INT const decayQueue = new Queue(); async function deleteCountersMarkedToBeDeleted(): Promise { await dataSource.getRepository(Counter).createQueryBuilder().where("delete_at <= NOW()").delete().execute(); } async function deleteTriggersMarkedToBeDeleted(): Promise { await dataSource.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; private counterValues: Repository; private counterTriggers: Repository; private counterTriggerStates: Repository; constructor(guildId) { super(guildId); this.counters = dataSource.getRepository(Counter); this.counterValues = dataSource.getRepository(CounterValue); this.counterTriggers = dataSource.getRepository(CounterTrigger); this.counterTriggerStates = dataSource.getRepository(CounterTriggerState); } async findOrCreateCounter(name: string, perChannel: boolean, perUser: boolean): Promise { 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) { await this.counters.update({ id: existing.id }, { delete_at: null }); 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 { const criteria: FindOptionsWhere = { guild_id: this.guildId, delete_at: IsNull(), }; if (idsToKeep.length) { criteria.id = Not(In(idsToKeep)); } const deleteAt = moment.utc().add(DELETE_UNUSED_COUNTERS_AFTER, "ms").format(DBDateFormat); await this.counters.update(criteria, { delete_at: deleteAt, }); } async deleteCountersMarkedToBeDeleted(): Promise { await this.counters.createQueryBuilder().where("delete_at <= NOW()").delete().execute(); } async changeCounterValue( id: number, channelId: string | null, userId: string | null, change: number, initialValue: number, ): Promise { 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}, ${MIN_COUNTER_VALUE})`; 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(initialValue + change, 0)], ); } async setCounterValue(id: number, channelId: string | null, userId: string | null, value: number): Promise { 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], ); } 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; } const decayAmountToApply = Math.round((diffFromLastDecayMs / decayPeriodMs) * decayAmount); if (decayAmountToApply === 0 || Number.isNaN(decayAmountToApply)) { return; } // 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}, ${MIN_COUNTER_VALUE})` : `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, }, ); }); } 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, }, ); } } async deleteTriggersMarkedToBeDeleted(): Promise { await this.counterTriggers.createQueryBuilder().where("delete_at <= NOW()").delete().execute(); } async initCounterTrigger( counterId: number, triggerName: string, comparisonOp: TriggerComparisonOp, comparisonValue: number, reverseComparisonOp: TriggerComparisonOp, reverseComparisonValue: number, ): Promise { if (!isValidCounterComparisonOp(comparisonOp)) { throw new Error(`Invalid comparison op: ${comparisonOp}`); } if (!isValidCounterComparisonOp(reverseComparisonOp)) { throw new Error(`Invalid comparison op: ${reverseComparisonOp}`); } if (typeof comparisonValue !== "number") { throw new Error(`Invalid comparison value: ${comparisonValue}`); } if (typeof reverseComparisonValue !== "number") { throw new Error(`Invalid comparison value: ${reverseComparisonValue}`); } return dataSource.transaction(async (entityManager) => { const existing = await entityManager.findOne(CounterTrigger, { where: { counter_id: counterId, name: triggerName, }, }); if (existing) { // Since all existing triggers are marked as to-be-deleted before they are re-initialized, this needs to be reset await entityManager.update(CounterTrigger, existing.id, { comparison_op: comparisonOp, comparison_value: comparisonValue, reverse_comparison_op: reverseComparisonOp, reverse_comparison_value: reverseComparisonValue, delete_at: null, }); return existing; } const insertResult = await entityManager.insert(CounterTrigger, { counter_id: counterId, name: triggerName, comparison_op: comparisonOp, comparison_value: comparisonValue, reverse_comparison_op: reverseComparisonOp, reverse_comparison_value: reverseComparisonValue, }); return (await entityManager.findOne(CounterTrigger, { where: { id: 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 { channelId = channelId || "0"; userId = userId || "0"; return dataSource.transaction(async (entityManager) => { const previouslyTriggered = await entityManager.findOne(CounterTriggerState, { where: { 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 }) .andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id }) .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> { return dataSource.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 }) .andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id }) .andWhere("triggerStates.id IS NULL") .getMany(); if (matchingValues.length) { await entityManager.insert( CounterTriggerState, matchingValues.map((row) => ({ trigger_id: counterTrigger.id, channel_id: row.channel_id, user_id: row.user_id, })), ); } 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 { channelId = channelId || "0"; userId = userId || "0"; return dataSource.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 }, ) .where(`cv.value ${counterTrigger.reverse_comparison_op} :value`, { value: counterTrigger.reverse_comparison_value, }) .andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id }) .andWhere(`cv.channel_id = :channelId AND cv.user_id = :userId`, { channelId, userId }) .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> { return dataSource.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 }, ) .where(`cv.value ${counterTrigger.reverse_comparison_op} :value`, { value: counterTrigger.reverse_comparison_value, }) .andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id }) .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, })); }); } async getCurrentValue( counterId: number, channelId: string | null, userId: string | null, ): Promise { const value = await this.counterValues.findOne({ where: { counter_id: counterId, channel_id: channelId || "0", user_id: userId || "0", }, }); return value?.value; } async resetAllCounterValues(counterId: number): Promise { // Foreign keys will remove any related triggers and counter values await this.counters.delete({ id: counterId, }); } }