diff --git a/backend/src/data/GuildCounters.ts b/backend/src/data/GuildCounters.ts new file mode 100644 index 00000000..1ec39214 --- /dev/null +++ b/backend/src/data/GuildCounters.ts @@ -0,0 +1,473 @@ +import { BaseGuildRepository } from "./BaseGuildRepository"; +import { getRepository, In, IsNull, LessThan, Not, Repository } from "typeorm"; +import { Counter } from "./entities/Counter"; +import { CounterValue } from "./entities/CounterValue"; +import { CounterTrigger, TRIGGER_COMPARISON_OPS, TriggerComparisonOp } from "./entities/CounterTrigger"; +import { CounterTriggerState } from "./entities/CounterTriggerState"; +import moment from "moment-timezone"; +import { DAYS, DBDateFormat, HOURS, MINUTES } from "../utils"; +import { connection } from "./db"; + +const comparisonStringRegex = new RegExp(`^(${TRIGGER_COMPARISON_OPS.join("|")})([1-9]\d*)$`); + +/** + * @return Parsed comparison op and value, or null if the comparison string was invalid + */ +export function parseCondition(str: string): [TriggerComparisonOp, number] | null { + const matches = str.match(comparisonStringRegex); + return matches ? [matches[1] as TriggerComparisonOp, parseInt(matches[2], 10)] : null; +} + +export function buildConditionString(comparisonOp: TriggerComparisonOp, comparisonValue: number): string { + return `${comparisonOp}${comparisonValue}`; +} + +function isValidComparisonOp(op: string): boolean { + return TRIGGER_COMPARISON_OPS.includes(op as any); +} + +const REVERSE_OPS: Record = { + "=": "!=", + "!=": "=", + ">": "<=", + "<": ">=", + ">=": "<", + "<=": ">", +}; + +function getReverseComparisonOp(op: TriggerComparisonOp): TriggerComparisonOp { + return REVERSE_OPS[op]; +} + +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 + +async function deleteCountersMarkedToBeDeleted(): Promise { + await getRepository(Counter) + .createQueryBuilder() + .where("delete_at <= NOW()") + .delete() + .execute(); +} + +async function deleteTriggersMarkedToBeDeleted(): Promise { + 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; + private counterValues: Repository; + private counterTriggers: Repository; + private counterTriggerStates: Repository; + + 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 { + 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) { + 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 { + if (idsToKeep.length === 0) { + return; + } + + const deleteAt = moment + .utc() + .add(DELETE_UNUSED_COUNTERS_AFTER, "ms") + .format(DBDateFormat); + await this.counters.update( + { + guild_id: this.guildId, + id: Not(In(idsToKeep)), + delete_at: IsNull(), + }, + { + 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): 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}, 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 { + 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], + ); + } + + async decay(id: number, decayPeriodMs: number, decayAmount: number) { + const now = moment.utc().format(DBDateFormat); + + 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); + + const rawUpdate = + decayAmountToApply >= 0 + ? `GREATEST(value - ${decayAmountToApply}, 0)` + : `LEAST(value + ${Math.abs(decayAmountToApply)}, ${MAX_COUNTER_VALUE})`; + + await this.counterValues.update( + { + counter_id: id, + }, + { + value: () => rawUpdate, + }, + ); + + await this.counters.update( + { + id, + }, + { + last_decay_at: now, + }, + ); + } + + async markAllTriggersTobeDeleted() { + const deleteAt = moment + .utc() + .add(DELETE_UNUSED_COUNTER_TRIGGERS_AFTER, "ms") + .format(DBDateFormat); + await this.counterTriggers.update( + {}, + { + delete_at: deleteAt, + }, + ); + } + + async deleteTriggersMarkedToBeDeleted(): Promise { + await this.counterTriggers + .createQueryBuilder() + .where("delete_at <= NOW()") + .delete() + .execute(); + } + + async initCounterTrigger( + counterId: number, + comparisonOp: TriggerComparisonOp, + comparisonValue: number, + ): Promise { + if (!isValidComparisonOp(comparisonOp)) { + throw new Error(`Invalid comparison op: ${comparisonOp}`); + } + + if (typeof comparisonValue !== "number") { + throw new Error(`Invalid comparison value: ${comparisonValue}`); + } + + return connection.transaction(async entityManager => { + const existing = await entityManager.findOne(CounterTrigger, { + counter_id: counterId, + comparison_op: comparisonOp, + comparison_value: comparisonValue, + }); + + 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, { delete_at: null }); + return existing; + } + + const insertResult = await entityManager.insert(CounterTrigger, { + counter_id: counterId, + comparison_op: comparisonOp, + comparison_value: comparisonValue, + }); + + 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 { + 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 }) + .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 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 }) + .andWhere("triggerStates.id IS NULL") + .getMany(); + + if (matchingValues.length) { + await entityManager.insert( + CounterTriggerState, + matchingValues.map(row => ({ + trigger_id: counterTrigger.id, + channelId: row.channel_id, + userId: 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 connection.transaction(async entityManager => { + const reverseOp = getReverseComparisonOp(counterTrigger.comparison_op); + 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 ${reverseOp} :value`, { value: counterTrigger.comparison_value }) + .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 connection.transaction(async entityManager => { + const reverseOp = getReverseComparisonOp(counterTrigger.comparison_op); + 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 ${reverseOp} :value`, { value: counterTrigger.comparison_value }) + .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, + })); + }); + } +} diff --git a/backend/src/data/entities/Counter.ts b/backend/src/data/entities/Counter.ts new file mode 100644 index 00000000..18f0d078 --- /dev/null +++ b/backend/src/data/entities/Counter.ts @@ -0,0 +1,25 @@ +import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; + +@Entity("counters") +export class Counter { + @PrimaryGeneratedColumn() + id: number; + + @Column() + guild_id: string; + + @Column() + name: string; + + @Column() + per_channel: boolean; + + @Column() + per_user: boolean; + + @Column() + last_decay_at: string; + + @Column({ type: "datetime", nullable: true }) + delete_at: string | null; +} diff --git a/backend/src/data/entities/CounterTrigger.ts b/backend/src/data/entities/CounterTrigger.ts new file mode 100644 index 00000000..7cf20700 --- /dev/null +++ b/backend/src/data/entities/CounterTrigger.ts @@ -0,0 +1,23 @@ +import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; + +export const TRIGGER_COMPARISON_OPS = ["=", "!=", ">", "<", ">=", "<="] as const; + +export type TriggerComparisonOp = typeof TRIGGER_COMPARISON_OPS[number]; + +@Entity("counter_triggers") +export class CounterTrigger { + @PrimaryGeneratedColumn() + id: number; + + @Column() + counter_id: number; + + @Column({ type: "varchar" }) + comparison_op: TriggerComparisonOp; + + @Column() + comparison_value: number; + + @Column({ type: "datetime", nullable: true }) + delete_at: string | null; +} diff --git a/backend/src/data/entities/CounterTriggerState.ts b/backend/src/data/entities/CounterTriggerState.ts new file mode 100644 index 00000000..ecb5d0dd --- /dev/null +++ b/backend/src/data/entities/CounterTriggerState.ts @@ -0,0 +1,17 @@ +import { Column, Entity, PrimaryColumn } from "typeorm"; + +@Entity("counter_trigger_states") +export class CounterTriggerState { + @Column() + @PrimaryColumn() + id: string; + + @Column() + trigger_id: number; + + @Column({ type: "bigint" }) + channel_id: string; + + @Column({ type: "bigint" }) + user_id: string; +} diff --git a/backend/src/data/entities/CounterValue.ts b/backend/src/data/entities/CounterValue.ts new file mode 100644 index 00000000..5647a6ec --- /dev/null +++ b/backend/src/data/entities/CounterValue.ts @@ -0,0 +1,20 @@ +import { Column, Entity, PrimaryColumn } from "typeorm"; + +@Entity("counter_values") +export class CounterValue { + @Column() + @PrimaryColumn() + id: string; + + @Column() + counter_id: number; + + @Column({ type: "bigint" }) + channel_id: string; + + @Column({ type: "bigint" }) + user_id: string; + + @Column() + value: number; +} diff --git a/backend/src/migrations/1612010765767-CreateCounterTables.ts b/backend/src/migrations/1612010765767-CreateCounterTables.ts new file mode 100644 index 00000000..48643c4f --- /dev/null +++ b/backend/src/migrations/1612010765767-CreateCounterTables.ts @@ -0,0 +1,203 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class CreateCounterTables1612010765767 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "counters", + columns: [ + { + name: "id", + type: "int", + isPrimary: true, + isGenerated: true, + generationStrategy: "increment", + }, + { + name: "guild_id", + type: "bigint", + }, + { + name: "name", + type: "varchar", + length: "255", + }, + { + name: "per_channel", + type: "boolean", + }, + { + name: "per_user", + type: "boolean", + }, + { + name: "last_decay_at", + type: "datetime", + }, + { + name: "delete_at", + type: "datetime", + isNullable: true, + default: null, + }, + ], + indices: [ + { + columnNames: ["guild_id", "name"], + isUnique: true, + }, + { + columnNames: ["delete_at"], + }, + ], + }), + ); + + await queryRunner.createTable( + new Table({ + name: "counter_values", + columns: [ + { + name: "id", + type: "bigint", + isPrimary: true, + isGenerated: true, + generationStrategy: "increment", + }, + { + name: "counter_id", + type: "int", + }, + { + name: "channel_id", + type: "bigint", + }, + { + name: "user_id", + type: "bigint", + }, + { + name: "value", + type: "int", + }, + ], + indices: [ + { + columnNames: ["counter_id", "channel_id", "user_id"], + isUnique: true, + }, + ], + foreignKeys: [ + { + columnNames: ["counter_id"], + referencedTableName: "counters", + referencedColumnNames: ["id"], + onDelete: "CASCADE", + onUpdate: "CASCADE", + }, + ], + }), + ); + + await queryRunner.createTable( + new Table({ + name: "counter_triggers", + columns: [ + { + name: "id", + type: "int", + isPrimary: true, + isGenerated: true, + generationStrategy: "increment", + }, + { + name: "counter_id", + type: "int", + }, + { + name: "comparison_op", + type: "varchar", + length: "16", + }, + { + name: "comparison_value", + type: "int", + }, + { + name: "delete_at", + type: "datetime", + isNullable: true, + default: null, + }, + ], + indices: [ + { + columnNames: ["counter_id", "comparison_op", "comparison_value"], + isUnique: true, + }, + { + columnNames: ["delete_at"], + }, + ], + foreignKeys: [ + { + columnNames: ["counter_id"], + referencedTableName: "counters", + referencedColumnNames: ["id"], + onDelete: "CASCADE", + onUpdate: "CASCADE", + }, + ], + }), + ); + + await queryRunner.createTable( + new Table({ + name: "counter_trigger_states", + columns: [ + { + name: "id", + type: "bigint", + isPrimary: true, + isGenerated: true, + generationStrategy: "increment", + }, + { + name: "trigger_id", + type: "int", + }, + { + name: "channel_id", + type: "bigint", + }, + { + name: "user_id", + type: "bigint", + }, + ], + indices: [ + { + columnNames: ["trigger_id", "channel_id", "user_id"], + isUnique: true, + }, + ], + foreignKeys: [ + { + columnNames: ["trigger_id"], + referencedTableName: "counter_triggers", + referencedColumnNames: ["id"], + onDelete: "CASCADE", + onUpdate: "CASCADE", + }, + ], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("counter_trigger_states"); + await queryRunner.dropTable("counter_triggers"); + await queryRunner.dropTable("counter_values"); + await queryRunner.dropTable("counters"); + } +} diff --git a/backend/src/plugins/Automod/AutomodPlugin.ts b/backend/src/plugins/Automod/AutomodPlugin.ts index 84936aa6..a901b460 100644 --- a/backend/src/plugins/Automod/AutomodPlugin.ts +++ b/backend/src/plugins/Automod/AutomodPlugin.ts @@ -28,6 +28,9 @@ import { LogType } from "../../data/LogType"; import { logger } from "../../logger"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners"; import { RunAutomodOnMemberUpdate } from "./events/RunAutomodOnMemberUpdate"; +import { CountersPlugin } from "../Counters/CountersPlugin"; +import { parseCondition } from "../../data/GuildCounters"; +import { runAutomodOnCounterTrigger } from "./events/runAutomodOnCounterTrigger"; const defaultOptions = { config: { @@ -53,7 +56,7 @@ const defaultOptions = { }; /** - * Config preprocessor to set default values for triggers + * Config preprocessor to set default values for triggers and perform extra validation */ const configPreprocessor: ConfigPreprocessorFn = options => { if (options.config?.rules) { @@ -108,6 +111,15 @@ const configPreprocessor: ConfigPreprocessorFn = options => { ]); } } + + if (triggerName === "counter") { + const parsedCondition = parseCondition(triggerObj[triggerName]!.condition); + if (parsedCondition == null) { + throw new StrictValidationError([ + `Invalid counter condition '${triggerObj[triggerName]!.condition}' in rule <${rule.name}>`, + ]); + } + } } } } @@ -151,7 +163,7 @@ export const AutomodPlugin = zeppelinGuildPlugin()("automod", showInDocs: true, info: pluginInfo, - dependencies: [LogsPlugin, ModActionsPlugin, MutesPlugin], + dependencies: [LogsPlugin, ModActionsPlugin, MutesPlugin, CountersPlugin], configSchema: ConfigSchema, defaultOptions, @@ -204,6 +216,39 @@ export const AutomodPlugin = zeppelinGuildPlugin()("automod", pluginData.state.cachedAntiraidLevel = await pluginData.state.antiraidLevels.get(); }, + async onAfterLoad(pluginData) { + const countersPlugin = pluginData.getPlugin(CountersPlugin); + + pluginData.state.onCounterTrigger = (name, condition, channelId, userId) => { + console.log("trigger", name, condition, channelId, userId); + runAutomodOnCounterTrigger(pluginData, name, condition, channelId, userId, false); + }; + + pluginData.state.onCounterReverseTrigger = (name, condition, channelId, userId) => { + console.log("reverseTrigger", name, condition, channelId, userId); + runAutomodOnCounterTrigger(pluginData, name, condition, channelId, userId, true); + }; + + const config = pluginData.config.get(); + for (const rule of Object.values(config.rules)) { + for (const trigger of rule.triggers) { + if (trigger.counter) { + await countersPlugin.initCounterTrigger(trigger.counter.name, trigger.counter.condition); + } + } + } + + countersPlugin.onCounterEvent("trigger", pluginData.state.onCounterTrigger); + countersPlugin.onCounterEvent("reverseTrigger", pluginData.state.onCounterReverseTrigger); + }, + + async onBeforeUnload(pluginData) { + const countersPlugin = pluginData.getPlugin(CountersPlugin); + + countersPlugin.offCounterEvent("trigger", pluginData.state.onCounterTrigger); + countersPlugin.offCounterEvent("reverseTrigger", pluginData.state.onCounterReverseTrigger); + }, + async onUnload(pluginData) { pluginData.state.queue.clear(); diff --git a/backend/src/plugins/Automod/actions/availableActions.ts b/backend/src/plugins/Automod/actions/availableActions.ts index 6b2c0b6f..21ebe945 100644 --- a/backend/src/plugins/Automod/actions/availableActions.ts +++ b/backend/src/plugins/Automod/actions/availableActions.ts @@ -12,6 +12,7 @@ import { AddRolesAction } from "./addRoles"; import { RemoveRolesAction } from "./removeRoles"; import { SetAntiraidLevelAction } from "./setAntiraidLevel"; import { ReplyAction } from "./reply"; +import { ChangeCounterAction } from "./changeCounter"; export const availableActions: Record> = { clean: CleanAction, @@ -26,6 +27,7 @@ export const availableActions: Record> = { remove_roles: RemoveRolesAction, set_antiraid_level: SetAntiraidLevelAction, reply: ReplyAction, + change_counter: ChangeCounterAction, }; export const AvailableActions = t.type({ @@ -41,4 +43,5 @@ export const AvailableActions = t.type({ remove_roles: RemoveRolesAction.configType, set_antiraid_level: SetAntiraidLevelAction.configType, reply: ReplyAction.configType, + change_counter: ChangeCounterAction.configType, }); diff --git a/backend/src/plugins/Automod/actions/changeCounter.ts b/backend/src/plugins/Automod/actions/changeCounter.ts new file mode 100644 index 00000000..a3db8d31 --- /dev/null +++ b/backend/src/plugins/Automod/actions/changeCounter.ts @@ -0,0 +1,27 @@ +import * as t from "io-ts"; +import { automodAction } from "../helpers"; +import { CountersPlugin } from "../../Counters/CountersPlugin"; + +export const ChangeCounterAction = automodAction({ + configType: t.type({ + name: t.string, + change: t.string, + }), + + defaultConfig: {}, + + async apply({ pluginData, contexts, actionConfig, matchResult }) { + const change = parseInt(actionConfig.change, 10); + if (Number.isNaN(change)) { + throw new Error("Invalid change number"); + } + + const countersPlugin = pluginData.getPlugin(CountersPlugin); + countersPlugin.changeCounterValue( + actionConfig.name, + contexts[0].message?.channel_id || null, + contexts[0].user?.id || null, + change, + ); + }, +}); diff --git a/backend/src/plugins/Automod/events/runAutomodOnCounterTrigger.ts b/backend/src/plugins/Automod/events/runAutomodOnCounterTrigger.ts new file mode 100644 index 00000000..a045f3de --- /dev/null +++ b/backend/src/plugins/Automod/events/runAutomodOnCounterTrigger.ts @@ -0,0 +1,27 @@ +import { GuildPluginData } from "knub"; +import { AutomodContext, AutomodPluginType } from "../types"; +import { runAutomod } from "../functions/runAutomod"; + +export function runAutomodOnCounterTrigger( + pluginData: GuildPluginData, + counterName: string, + condition: string, + channelId: string | null, + userId: string | null, + reverse: boolean, +) { + const context: AutomodContext = { + timestamp: Date.now(), + counterTrigger: { + name: counterName, + condition, + channelId, + userId, + reverse, + }, + }; + + pluginData.state.queue.add(async () => { + await runAutomod(pluginData, context); + }); +} diff --git a/backend/src/plugins/Automod/functions/runAutomod.ts b/backend/src/plugins/Automod/functions/runAutomod.ts index 661d9f13..3d3af219 100644 --- a/backend/src/plugins/Automod/functions/runAutomod.ts +++ b/backend/src/plugins/Automod/functions/runAutomod.ts @@ -24,7 +24,7 @@ export async function runAutomod(pluginData: GuildPluginData, for (const [ruleName, rule] of Object.entries(config.rules)) { if (rule.enabled === false) continue; - if (!rule.affects_bots && (!user || user.bot)) continue; + if (!rule.affects_bots && (!user || user.bot) && !context.counterTrigger) continue; if (rule.cooldown && checkAndUpdateCooldown(pluginData, rule, context)) { return; diff --git a/backend/src/plugins/Automod/triggers/availableTriggers.ts b/backend/src/plugins/Automod/triggers/availableTriggers.ts index e178a256..2c3dbd5a 100644 --- a/backend/src/plugins/Automod/triggers/availableTriggers.ts +++ b/backend/src/plugins/Automod/triggers/availableTriggers.ts @@ -17,6 +17,7 @@ import { MemberJoinTrigger } from "./memberJoin"; import { RoleAddedTrigger } from "./roleAdded"; import { RoleRemovedTrigger } from "./roleRemoved"; import { StickerSpamTrigger } from "./stickerSpam"; +import { CounterTrigger } from "./counter"; export const availableTriggers: Record> = { match_words: MatchWordsTrigger, @@ -37,6 +38,8 @@ export const availableTriggers: Record character_spam: CharacterSpamTrigger, member_join_spam: MemberJoinSpamTrigger, sticker_spam: StickerSpamTrigger, + + counter: CounterTrigger, }; export const AvailableTriggers = t.type({ @@ -58,4 +61,6 @@ export const AvailableTriggers = t.type({ character_spam: CharacterSpamTrigger.configType, member_join_spam: MemberJoinSpamTrigger.configType, sticker_spam: StickerSpamTrigger.configType, + + counter: CounterTrigger.configType, }); diff --git a/backend/src/plugins/Automod/triggers/counter.ts b/backend/src/plugins/Automod/triggers/counter.ts new file mode 100644 index 00000000..6b3c3288 --- /dev/null +++ b/backend/src/plugins/Automod/triggers/counter.ts @@ -0,0 +1,46 @@ +import * as t from "io-ts"; +import { automodTrigger } from "../helpers"; +import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges"; +import { CountersPlugin } from "../../Counters/CountersPlugin"; +import { tNullable } from "../../../utils"; + +// tslint:disable-next-line +interface CounterTriggerResult {} + +export const CounterTrigger = automodTrigger()({ + configType: t.type({ + name: t.string, + condition: t.string, + reverse: tNullable(t.boolean), + }), + + defaultConfig: {}, + + async match({ triggerConfig, context, pluginData }) { + if (!context.counterTrigger) { + return; + } + + if (context.counterTrigger.name !== triggerConfig.name) { + return; + } + + if (context.counterTrigger.condition !== triggerConfig.condition) { + return; + } + + const reverse = triggerConfig.reverse ?? false; + if (context.counterTrigger.reverse !== reverse) { + return; + } + + return { + extra: {}, + }; + }, + + renderMatchInformation({ matchResult, pluginData, contexts, triggerConfig }) { + // TODO: Show user, channel, reverse + return `Matched counter \`${triggerConfig.name} ${triggerConfig.condition}\``; + }, +}); diff --git a/backend/src/plugins/Automod/types.ts b/backend/src/plugins/Automod/types.ts index 6b71e301..39be0f6a 100644 --- a/backend/src/plugins/Automod/types.ts +++ b/backend/src/plugins/Automod/types.ts @@ -13,6 +13,7 @@ import { GuildArchives } from "../../data/GuildArchives"; import { RecentActionType } from "./constants"; import Timeout = NodeJS.Timeout; import { RegExpRunner } from "../../RegExpRunner"; +import { CounterEvents } from "../Counters/types"; export const Rule = t.type({ enabled: t.boolean, @@ -86,6 +87,9 @@ export interface AutomodPluginType extends BasePluginType { onMessageCreateFn: any; onMessageUpdateFn: any; + + onCounterTrigger: CounterEvents["trigger"]; + onCounterReverseTrigger: CounterEvents["reverseTrigger"]; }; } @@ -93,6 +97,13 @@ export interface AutomodContext { timestamp: number; actioned?: boolean; + counterTrigger?: { + name: string; + condition: string; + channelId: string | null; + userId: string | null; + reverse: boolean; + }; user?: User; message?: SavedMessage; member?: Member; diff --git a/backend/src/plugins/Counters/CountersPlugin.ts b/backend/src/plugins/Counters/CountersPlugin.ts new file mode 100644 index 00000000..95f06924 --- /dev/null +++ b/backend/src/plugins/Counters/CountersPlugin.ts @@ -0,0 +1,121 @@ +import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint"; +import { ConfigSchema, CountersPluginType } from "./types"; +import { GuildCounters } from "../../data/GuildCounters"; +import { mapToPublicFn } from "../../pluginUtils"; +import { changeCounterValue } from "./functions/changeCounterValue"; +import { setCounterValue } from "./functions/setCounterValue"; +import { convertDelayStringToMS, MINUTES, SECONDS } from "../../utils"; +import { EventEmitter } from "events"; +import { onCounterEvent } from "./functions/onCounterEvent"; +import { offCounterEvent } from "./functions/offCounterEvent"; +import { emitCounterEvent } from "./functions/emitCounterEvent"; +import { ConfigPreprocessorFn } from "knub/dist/config/configTypes"; +import { initCounterTrigger } from "./functions/initCounterTrigger"; +import { decayCounter } from "./functions/decayCounter"; +import { validateCondition } from "./functions/validateCondition"; +import { StrictValidationError } from "../../validatorUtils"; + +const MAX_COUNTERS = 5; + +const defaultOptions = { + config: { + counters: {}, + }, +}; + +const configPreprocessor: ConfigPreprocessorFn = options => { + for (const counter of Object.values(options.config?.counters || {})) { + counter.per_user = counter.per_user ?? false; + counter.per_channel = counter.per_channel ?? false; + counter.initial_value = counter.initial_value ?? 0; + } + + if (Object.values(options.config?.counters || {}).length > MAX_COUNTERS) { + throw new StrictValidationError([`You can only have at most ${MAX_COUNTERS} active counters`]); + } + + return options; +}; + +/** + * The Counters plugin keeps track of simple integer values that are tied to a user, channel, both, or neither — "counters". + * These values can be changed using the functions in the plugin's public interface. + * These values can also be set to automatically decay over time. + * + * Triggers can be registered that check for a specific condition, e.g. "when this counter is over 100". + * Triggers are checked against every time a counter's value changes, and will emit an event when triggered. + * A single trigger can only trigger once per user/channel/in general, depending on how specific the counter is (e.g. a per-user trigger can only trigger once per user). + * After being triggered, a trigger is "reset" if the counter value no longer matches the trigger (e.g. drops to 100 or below in the above example). After this, that trigger can be triggered again. + */ +export const CountersPlugin = zeppelinGuildPlugin()("counters", { + configSchema: ConfigSchema, + defaultOptions, + configPreprocessor, + + public: { + // Change a counter's value by a relative amount, e.g. +5 + changeCounterValue: mapToPublicFn(changeCounterValue), + // Set a counter's value to an absolute value + setCounterValue: mapToPublicFn(setCounterValue), + + // Initialize a trigger. Once initialized, events will be fired when this trigger is triggered. + initCounterTrigger: mapToPublicFn(initCounterTrigger), + + // Validate a trigger's condition string + validateCondition: mapToPublicFn(validateCondition), + + onCounterEvent: mapToPublicFn(onCounterEvent), + offCounterEvent: mapToPublicFn(offCounterEvent), + }, + + async onLoad(pluginData) { + pluginData.state.counters = new GuildCounters(pluginData.guild.id); + pluginData.state.events = new EventEmitter(); + + // Initialize and store the IDs of each of the counters internally + pluginData.state.counterIds = {}; + const config = pluginData.config.get(); + for (const [counterName, counter] of Object.entries(config.counters)) { + const dbCounter = await pluginData.state.counters.findOrCreateCounter( + counterName, + counter.per_channel, + counter.per_user, + ); + pluginData.state.counterIds[counterName] = dbCounter.id; + } + + // Mark old/unused counters to be deleted later + await pluginData.state.counters.markUnusedCountersToBeDeleted([...Object.values(pluginData.state.counterIds)]); + + // Start decay timers + pluginData.state.decayTimers = []; + for (const [counterName, counter] of Object.entries(config.counters)) { + if (!counter.decay) { + continue; + } + + const decay = counter.decay; + const decayPeriodMs = convertDelayStringToMS(decay.every)!; + pluginData.state.decayTimers.push( + setInterval(() => { + decayCounter(pluginData, counterName, decayPeriodMs, decay.amount); + }, 10 * SECONDS), + ); + } + + // Initially set the counter trigger map to just an empty map + // The actual triggers are added by other plugins via initCounterTrigger() + pluginData.state.counterTriggersByCounterId = new Map(); + + // Mark all triggers to be deleted later. This is cancelled/reset when a plugin adds the trigger again via initCounterTrigger(). + await pluginData.state.counters.markAllTriggersTobeDeleted(); + }, + + onUnload(pluginData) { + for (const interval of pluginData.state.decayTimers) { + clearInterval(interval); + } + + pluginData.state.events.removeAllListeners(); + }, +}); diff --git a/backend/src/plugins/Counters/functions/changeCounterValue.ts b/backend/src/plugins/Counters/functions/changeCounterValue.ts new file mode 100644 index 00000000..be214eb7 --- /dev/null +++ b/backend/src/plugins/Counters/functions/changeCounterValue.ts @@ -0,0 +1,48 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; +import { checkCounterTrigger } from "./checkCounterTrigger"; +import { checkReverseCounterTrigger } from "./checkReverseCounterTrigger"; + +export async function changeCounterValue( + pluginData: GuildPluginData, + counterName: string, + channelId: string | null, + userId: string | null, + change: number, +) { + const config = pluginData.config.get(); + const counter = config.counters[counterName]; + if (!counter) { + throw new Error(`Unknown counter: ${counterName}`); + } + + if (counter.per_channel && !channelId) { + throw new Error(`Counter is per channel but no channel ID was supplied`); + } + + if (counter.per_user && !userId) { + throw new Error(`Counter is per user but no user ID was supplied`); + } + + channelId = counter.per_channel ? channelId : null; + userId = counter.per_user ? userId : null; + + const counterId = pluginData.state.counterIds[counterName]; + const lock = await pluginData.locks.acquire(counterId.toString()); + + await pluginData.state.counters.changeCounterValue(counterId, channelId, userId, change); + + // Check for trigger matches, if any, when the counter value changes + const triggers = pluginData.state.counterTriggersByCounterId.get(counterId); + if (triggers) { + const triggersArr = Array.from(triggers.values()); + await Promise.all( + triggersArr.map(trigger => checkCounterTrigger(pluginData, counterName, trigger, channelId, userId)), + ); + await Promise.all( + triggersArr.map(trigger => checkReverseCounterTrigger(pluginData, counterName, trigger, channelId, userId)), + ); + } + + lock.unlock(); +} diff --git a/backend/src/plugins/Counters/functions/checkAllValuesForReverseTrigger.ts b/backend/src/plugins/Counters/functions/checkAllValuesForReverseTrigger.ts new file mode 100644 index 00000000..9336906e --- /dev/null +++ b/backend/src/plugins/Counters/functions/checkAllValuesForReverseTrigger.ts @@ -0,0 +1,23 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; +import { buildConditionString } from "../../../data/GuildCounters"; +import { CounterTrigger } from "../../../data/entities/CounterTrigger"; +import { emitCounterEvent } from "./emitCounterEvent"; + +export async function checkAllValuesForReverseTrigger( + pluginData: GuildPluginData, + counterName: string, + counterTrigger: CounterTrigger, +) { + const triggeredContexts = await pluginData.state.counters.checkAllValuesForReverseTrigger(counterTrigger); + for (const context of triggeredContexts) { + emitCounterEvent( + pluginData, + "reverseTrigger", + counterName, + buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value), + context.channelId, + context.userId, + ); + } +} diff --git a/backend/src/plugins/Counters/functions/checkAllValuesForTrigger.ts b/backend/src/plugins/Counters/functions/checkAllValuesForTrigger.ts new file mode 100644 index 00000000..277cff15 --- /dev/null +++ b/backend/src/plugins/Counters/functions/checkAllValuesForTrigger.ts @@ -0,0 +1,23 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; +import { buildConditionString } from "../../../data/GuildCounters"; +import { CounterTrigger } from "../../../data/entities/CounterTrigger"; +import { emitCounterEvent } from "./emitCounterEvent"; + +export async function checkAllValuesForTrigger( + pluginData: GuildPluginData, + counterName: string, + counterTrigger: CounterTrigger, +) { + const triggeredContexts = await pluginData.state.counters.checkAllValuesForTrigger(counterTrigger); + for (const context of triggeredContexts) { + emitCounterEvent( + pluginData, + "trigger", + counterName, + buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value), + context.channelId, + context.userId, + ); + } +} diff --git a/backend/src/plugins/Counters/functions/checkCounterTrigger.ts b/backend/src/plugins/Counters/functions/checkCounterTrigger.ts new file mode 100644 index 00000000..e45cf28d --- /dev/null +++ b/backend/src/plugins/Counters/functions/checkCounterTrigger.ts @@ -0,0 +1,25 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; +import { buildConditionString } from "../../../data/GuildCounters"; +import { CounterTrigger } from "../../../data/entities/CounterTrigger"; +import { emitCounterEvent } from "./emitCounterEvent"; + +export async function checkCounterTrigger( + pluginData: GuildPluginData, + counterName: string, + counterTrigger: CounterTrigger, + channelId: string | null, + userId: string | null, +) { + const triggered = await pluginData.state.counters.checkForTrigger(counterTrigger, channelId, userId); + if (triggered) { + await emitCounterEvent( + pluginData, + "trigger", + counterName, + buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value), + channelId, + userId, + ); + } +} diff --git a/backend/src/plugins/Counters/functions/checkReverseCounterTrigger.ts b/backend/src/plugins/Counters/functions/checkReverseCounterTrigger.ts new file mode 100644 index 00000000..5ed9b3d7 --- /dev/null +++ b/backend/src/plugins/Counters/functions/checkReverseCounterTrigger.ts @@ -0,0 +1,25 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; +import { buildConditionString } from "../../../data/GuildCounters"; +import { CounterTrigger } from "../../../data/entities/CounterTrigger"; +import { emitCounterEvent } from "./emitCounterEvent"; + +export async function checkReverseCounterTrigger( + pluginData: GuildPluginData, + counterName: string, + counterTrigger: CounterTrigger, + channelId: string | null, + userId: string | null, +) { + const triggered = await pluginData.state.counters.checkForReverseTrigger(counterTrigger, channelId, userId); + if (triggered) { + await emitCounterEvent( + pluginData, + "reverseTrigger", + counterName, + buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value), + channelId, + userId, + ); + } +} diff --git a/backend/src/plugins/Counters/functions/decayCounter.ts b/backend/src/plugins/Counters/functions/decayCounter.ts new file mode 100644 index 00000000..175cb158 --- /dev/null +++ b/backend/src/plugins/Counters/functions/decayCounter.ts @@ -0,0 +1,32 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; +import { checkAllValuesForTrigger } from "./checkAllValuesForTrigger"; +import { checkAllValuesForReverseTrigger } from "./checkAllValuesForReverseTrigger"; + +export async function decayCounter( + pluginData: GuildPluginData, + counterName: string, + decayPeriodMS: number, + decayAmount: number, +) { + const config = pluginData.config.get(); + const counter = config.counters[counterName]; + if (!counter) { + throw new Error(`Unknown counter: ${counterName}`); + } + + const counterId = pluginData.state.counterIds[counterName]; + const lock = await pluginData.locks.acquire(counterId.toString()); + + await pluginData.state.counters.decay(counterId, decayPeriodMS, decayAmount); + + // Check for trigger matches, if any, when the counter value changes + const triggers = pluginData.state.counterTriggersByCounterId.get(counterId); + if (triggers) { + const triggersArr = Array.from(triggers.values()); + await Promise.all(triggersArr.map(trigger => checkAllValuesForTrigger(pluginData, counterName, trigger))); + await Promise.all(triggersArr.map(trigger => checkAllValuesForReverseTrigger(pluginData, counterName, trigger))); + } + + lock.unlock(); +} diff --git a/backend/src/plugins/Counters/functions/emitCounterEvent.ts b/backend/src/plugins/Counters/functions/emitCounterEvent.ts new file mode 100644 index 00000000..ad131e54 --- /dev/null +++ b/backend/src/plugins/Counters/functions/emitCounterEvent.ts @@ -0,0 +1,10 @@ +import { CounterEvents, CountersPluginType } from "../types"; +import { GuildPluginData } from "knub"; + +export function emitCounterEvent( + pluginData: GuildPluginData, + event: TEvent, + ...rest: Parameters +) { + return pluginData.state.events.emit(event, ...rest); +} diff --git a/backend/src/plugins/Counters/functions/initCounterTrigger.ts b/backend/src/plugins/Counters/functions/initCounterTrigger.ts new file mode 100644 index 00000000..afc5e9c9 --- /dev/null +++ b/backend/src/plugins/Counters/functions/initCounterTrigger.ts @@ -0,0 +1,31 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; +import { parseCondition } from "../../../data/GuildCounters"; + +/** + * Initialize a counter trigger. + * After a counter trigger has been initialized, it will be checked against whenever the counter's values change. + * If the trigger is triggered, an event is emitted. + */ +export async function initCounterTrigger( + pluginData: GuildPluginData, + counterName: string, + condition: string, +) { + const counterId = pluginData.state.counterIds[counterName]; + if (!counterId) { + throw new Error(`Unknown counter: ${counterName}`); + } + + const parsedComparison = parseCondition(condition); + if (!parsedComparison) { + throw new Error(`Invalid comparison string: ${condition}`); + } + + const [comparisonOp, comparisonValue] = parsedComparison; + const counterTrigger = await pluginData.state.counters.initCounterTrigger(counterId, comparisonOp, comparisonValue); + if (!pluginData.state.counterTriggersByCounterId.has(counterId)) { + pluginData.state.counterTriggersByCounterId.set(counterId, new Map()); + } + pluginData.state.counterTriggersByCounterId.get(counterId)!.set(counterTrigger.id, counterTrigger); +} diff --git a/backend/src/plugins/Counters/functions/offCounterEvent.ts b/backend/src/plugins/Counters/functions/offCounterEvent.ts new file mode 100644 index 00000000..66ee9624 --- /dev/null +++ b/backend/src/plugins/Counters/functions/offCounterEvent.ts @@ -0,0 +1,9 @@ +import { CounterEventEmitter, CountersPluginType } from "../types"; +import { GuildPluginData } from "knub"; + +export function offCounterEvent( + pluginData: GuildPluginData, + ...rest: Parameters +) { + return pluginData.state.events.off(...rest); +} diff --git a/backend/src/plugins/Counters/functions/onCounterEvent.ts b/backend/src/plugins/Counters/functions/onCounterEvent.ts new file mode 100644 index 00000000..1a3aa6fd --- /dev/null +++ b/backend/src/plugins/Counters/functions/onCounterEvent.ts @@ -0,0 +1,10 @@ +import { CounterEvents, CountersPluginType } from "../types"; +import { GuildPluginData } from "knub"; + +export function onCounterEvent( + pluginData: GuildPluginData, + event: TEvent, + listener: CounterEvents[TEvent], +) { + return pluginData.state.events.on(event, listener); +} diff --git a/backend/src/plugins/Counters/functions/setCounterValue.ts b/backend/src/plugins/Counters/functions/setCounterValue.ts new file mode 100644 index 00000000..2eefed8f --- /dev/null +++ b/backend/src/plugins/Counters/functions/setCounterValue.ts @@ -0,0 +1,45 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; +import { checkCounterTrigger } from "./checkCounterTrigger"; +import { checkReverseCounterTrigger } from "./checkReverseCounterTrigger"; + +export async function setCounterValue( + pluginData: GuildPluginData, + counterName: string, + channelId: string | null, + userId: string | null, + value: number, +) { + const config = pluginData.config.get(); + const counter = config.counters[counterName]; + if (!counter) { + throw new Error(`Unknown counter: ${counterName}`); + } + + if (counter.per_channel && !channelId) { + throw new Error(`Counter is per channel but no channel ID was supplied`); + } + + if (counter.per_user && !userId) { + throw new Error(`Counter is per user but no user ID was supplied`); + } + + const counterId = pluginData.state.counterIds[counterName]; + const lock = await pluginData.locks.acquire(counterId.toString()); + + await pluginData.state.counters.setCounterValue(counterId, channelId, userId, value); + + // Check for trigger matches, if any, when the counter value changes + const triggers = pluginData.state.counterTriggersByCounterId.get(counterId); + if (triggers) { + const triggersArr = Array.from(triggers.values()); + await Promise.all( + triggersArr.map(trigger => checkCounterTrigger(pluginData, counterName, trigger, channelId, userId)), + ); + await Promise.all( + triggersArr.map(trigger => checkReverseCounterTrigger(pluginData, counterName, trigger, channelId, userId)), + ); + } + + lock.unlock(); +} diff --git a/backend/src/plugins/Counters/functions/validateCondition.ts b/backend/src/plugins/Counters/functions/validateCondition.ts new file mode 100644 index 00000000..ca5e2e88 --- /dev/null +++ b/backend/src/plugins/Counters/functions/validateCondition.ts @@ -0,0 +1,8 @@ +import { GuildPluginData } from "knub"; +import { CountersPluginType } from "../types"; +import { parseCondition } from "../../../data/GuildCounters"; + +export function validateCondition(pluginData: GuildPluginData, condition: string) { + const parsed = parseCondition(condition); + return parsed != null; +} diff --git a/backend/src/plugins/Counters/types.ts b/backend/src/plugins/Counters/types.ts new file mode 100644 index 00000000..e34965ee --- /dev/null +++ b/backend/src/plugins/Counters/types.ts @@ -0,0 +1,47 @@ +import * as t from "io-ts"; +import { BasePluginType } from "knub"; +import { GuildCounters } from "../../data/GuildCounters"; +import { tDelayString, tNullable } from "../../utils"; +import { EventEmitter } from "events"; +import { CounterTrigger } from "../../data/entities/CounterTrigger"; +import Timeout = NodeJS.Timeout; + +export const Counter = t.type({ + per_channel: t.boolean, + per_user: t.boolean, + initial_value: t.number, + decay: tNullable( + t.type({ + amount: t.number, + every: tDelayString, + }), + ), +}); +export type TCounter = t.TypeOf; + +export const ConfigSchema = t.type({ + counters: t.record(t.string, Counter), +}); +export type TConfigSchema = t.TypeOf; + +export interface CounterEvents { + trigger: (name: string, condition: string, channelId: string | null, userId: string | null) => void; + reverseTrigger: (name: string, condition: string, channelId: string | null, userId: string | null) => void; +} + +export interface CounterEventEmitter extends EventEmitter { + on(event: U, listener: CounterEvents[U]): this; + + emit(event: U, ...args: Parameters): boolean; +} + +export interface CountersPluginType extends BasePluginType { + config: TConfigSchema; + state: { + counters: GuildCounters; + counterIds: Record; + decayTimers: Timeout[]; + events: CounterEventEmitter; + counterTriggersByCounterId: Map>; + }; +} diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 5777e343..e1e4e936 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -32,6 +32,7 @@ import { CustomEventsPlugin } from "./CustomEvents/CustomEventsPlugin"; import { BotControlPlugin } from "./BotControl/BotControlPlugin"; import { GuildAccessMonitorPlugin } from "./GuildAccessMonitor/GuildAccessMonitorPlugin"; import { TimeAndDatePlugin } from "./TimeAndDate/TimeAndDatePlugin"; +import { CountersPlugin } from "./Counters/CountersPlugin"; // prettier-ignore export const guildPlugins: Array> = [ @@ -65,6 +66,7 @@ export const guildPlugins: Array> = [ CompanionChannelsPlugin, CustomEventsPlugin, TimeAndDatePlugin, + CountersPlugin, ]; // prettier-ignore