Counters v0.9
Includes automod trigger/action. No user-facing commands yet.
This commit is contained in:
parent
ec37cf27a2
commit
c3407e2d5d
29 changed files with 1387 additions and 3 deletions
473
backend/src/data/GuildCounters.ts
Normal file
473
backend/src/data/GuildCounters.ts
Normal file
|
@ -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<TriggerComparisonOp, TriggerComparisonOp> = {
|
||||||
|
"=": "!=",
|
||||||
|
"!=": "=",
|
||||||
|
">": "<=",
|
||||||
|
"<": ">=",
|
||||||
|
">=": "<",
|
||||||
|
"<=": ">",
|
||||||
|
};
|
||||||
|
|
||||||
|
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<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) {
|
||||||
|
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> {
|
||||||
|
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<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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
await this.counterTriggers
|
||||||
|
.createQueryBuilder()
|
||||||
|
.where("delete_at <= NOW()")
|
||||||
|
.delete()
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async initCounterTrigger(
|
||||||
|
counterId: number,
|
||||||
|
comparisonOp: TriggerComparisonOp,
|
||||||
|
comparisonValue: number,
|
||||||
|
): Promise<CounterTrigger> {
|
||||||
|
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<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 })
|
||||||
|
.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 })
|
||||||
|
.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<boolean> {
|
||||||
|
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<Array<{ channelId: string; userId: string }>> {
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
25
backend/src/data/entities/Counter.ts
Normal file
25
backend/src/data/entities/Counter.ts
Normal file
|
@ -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;
|
||||||
|
}
|
23
backend/src/data/entities/CounterTrigger.ts
Normal file
23
backend/src/data/entities/CounterTrigger.ts
Normal file
|
@ -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;
|
||||||
|
}
|
17
backend/src/data/entities/CounterTriggerState.ts
Normal file
17
backend/src/data/entities/CounterTriggerState.ts
Normal file
|
@ -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;
|
||||||
|
}
|
20
backend/src/data/entities/CounterValue.ts
Normal file
20
backend/src/data/entities/CounterValue.ts
Normal file
|
@ -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;
|
||||||
|
}
|
203
backend/src/migrations/1612010765767-CreateCounterTables.ts
Normal file
203
backend/src/migrations/1612010765767-CreateCounterTables.ts
Normal file
|
@ -0,0 +1,203 @@
|
||||||
|
import { MigrationInterface, QueryRunner, Table } from "typeorm";
|
||||||
|
|
||||||
|
export class CreateCounterTables1612010765767 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||||
|
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<any> {
|
||||||
|
await queryRunner.dropTable("counter_trigger_states");
|
||||||
|
await queryRunner.dropTable("counter_triggers");
|
||||||
|
await queryRunner.dropTable("counter_values");
|
||||||
|
await queryRunner.dropTable("counters");
|
||||||
|
}
|
||||||
|
}
|
|
@ -28,6 +28,9 @@ import { LogType } from "../../data/LogType";
|
||||||
import { logger } from "../../logger";
|
import { logger } from "../../logger";
|
||||||
import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners";
|
import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners";
|
||||||
import { RunAutomodOnMemberUpdate } from "./events/RunAutomodOnMemberUpdate";
|
import { RunAutomodOnMemberUpdate } from "./events/RunAutomodOnMemberUpdate";
|
||||||
|
import { CountersPlugin } from "../Counters/CountersPlugin";
|
||||||
|
import { parseCondition } from "../../data/GuildCounters";
|
||||||
|
import { runAutomodOnCounterTrigger } from "./events/runAutomodOnCounterTrigger";
|
||||||
|
|
||||||
const defaultOptions = {
|
const defaultOptions = {
|
||||||
config: {
|
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<AutomodPluginType> = options => {
|
const configPreprocessor: ConfigPreprocessorFn<AutomodPluginType> = options => {
|
||||||
if (options.config?.rules) {
|
if (options.config?.rules) {
|
||||||
|
@ -108,6 +111,15 @@ const configPreprocessor: ConfigPreprocessorFn<AutomodPluginType> = 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<AutomodPluginType>()("automod",
|
||||||
showInDocs: true,
|
showInDocs: true,
|
||||||
info: pluginInfo,
|
info: pluginInfo,
|
||||||
|
|
||||||
dependencies: [LogsPlugin, ModActionsPlugin, MutesPlugin],
|
dependencies: [LogsPlugin, ModActionsPlugin, MutesPlugin, CountersPlugin],
|
||||||
|
|
||||||
configSchema: ConfigSchema,
|
configSchema: ConfigSchema,
|
||||||
defaultOptions,
|
defaultOptions,
|
||||||
|
@ -204,6 +216,39 @@ export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()("automod",
|
||||||
pluginData.state.cachedAntiraidLevel = await pluginData.state.antiraidLevels.get();
|
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) {
|
async onUnload(pluginData) {
|
||||||
pluginData.state.queue.clear();
|
pluginData.state.queue.clear();
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { AddRolesAction } from "./addRoles";
|
||||||
import { RemoveRolesAction } from "./removeRoles";
|
import { RemoveRolesAction } from "./removeRoles";
|
||||||
import { SetAntiraidLevelAction } from "./setAntiraidLevel";
|
import { SetAntiraidLevelAction } from "./setAntiraidLevel";
|
||||||
import { ReplyAction } from "./reply";
|
import { ReplyAction } from "./reply";
|
||||||
|
import { ChangeCounterAction } from "./changeCounter";
|
||||||
|
|
||||||
export const availableActions: Record<string, AutomodActionBlueprint<any>> = {
|
export const availableActions: Record<string, AutomodActionBlueprint<any>> = {
|
||||||
clean: CleanAction,
|
clean: CleanAction,
|
||||||
|
@ -26,6 +27,7 @@ export const availableActions: Record<string, AutomodActionBlueprint<any>> = {
|
||||||
remove_roles: RemoveRolesAction,
|
remove_roles: RemoveRolesAction,
|
||||||
set_antiraid_level: SetAntiraidLevelAction,
|
set_antiraid_level: SetAntiraidLevelAction,
|
||||||
reply: ReplyAction,
|
reply: ReplyAction,
|
||||||
|
change_counter: ChangeCounterAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AvailableActions = t.type({
|
export const AvailableActions = t.type({
|
||||||
|
@ -41,4 +43,5 @@ export const AvailableActions = t.type({
|
||||||
remove_roles: RemoveRolesAction.configType,
|
remove_roles: RemoveRolesAction.configType,
|
||||||
set_antiraid_level: SetAntiraidLevelAction.configType,
|
set_antiraid_level: SetAntiraidLevelAction.configType,
|
||||||
reply: ReplyAction.configType,
|
reply: ReplyAction.configType,
|
||||||
|
change_counter: ChangeCounterAction.configType,
|
||||||
});
|
});
|
||||||
|
|
27
backend/src/plugins/Automod/actions/changeCounter.ts
Normal file
27
backend/src/plugins/Automod/actions/changeCounter.ts
Normal file
|
@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { GuildPluginData } from "knub";
|
||||||
|
import { AutomodContext, AutomodPluginType } from "../types";
|
||||||
|
import { runAutomod } from "../functions/runAutomod";
|
||||||
|
|
||||||
|
export function runAutomodOnCounterTrigger(
|
||||||
|
pluginData: GuildPluginData<AutomodPluginType>,
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
|
@ -24,7 +24,7 @@ export async function runAutomod(pluginData: GuildPluginData<AutomodPluginType>,
|
||||||
|
|
||||||
for (const [ruleName, rule] of Object.entries(config.rules)) {
|
for (const [ruleName, rule] of Object.entries(config.rules)) {
|
||||||
if (rule.enabled === false) continue;
|
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)) {
|
if (rule.cooldown && checkAndUpdateCooldown(pluginData, rule, context)) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { MemberJoinTrigger } from "./memberJoin";
|
||||||
import { RoleAddedTrigger } from "./roleAdded";
|
import { RoleAddedTrigger } from "./roleAdded";
|
||||||
import { RoleRemovedTrigger } from "./roleRemoved";
|
import { RoleRemovedTrigger } from "./roleRemoved";
|
||||||
import { StickerSpamTrigger } from "./stickerSpam";
|
import { StickerSpamTrigger } from "./stickerSpam";
|
||||||
|
import { CounterTrigger } from "./counter";
|
||||||
|
|
||||||
export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>> = {
|
export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>> = {
|
||||||
match_words: MatchWordsTrigger,
|
match_words: MatchWordsTrigger,
|
||||||
|
@ -37,6 +38,8 @@ export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>
|
||||||
character_spam: CharacterSpamTrigger,
|
character_spam: CharacterSpamTrigger,
|
||||||
member_join_spam: MemberJoinSpamTrigger,
|
member_join_spam: MemberJoinSpamTrigger,
|
||||||
sticker_spam: StickerSpamTrigger,
|
sticker_spam: StickerSpamTrigger,
|
||||||
|
|
||||||
|
counter: CounterTrigger,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AvailableTriggers = t.type({
|
export const AvailableTriggers = t.type({
|
||||||
|
@ -58,4 +61,6 @@ export const AvailableTriggers = t.type({
|
||||||
character_spam: CharacterSpamTrigger.configType,
|
character_spam: CharacterSpamTrigger.configType,
|
||||||
member_join_spam: MemberJoinSpamTrigger.configType,
|
member_join_spam: MemberJoinSpamTrigger.configType,
|
||||||
sticker_spam: StickerSpamTrigger.configType,
|
sticker_spam: StickerSpamTrigger.configType,
|
||||||
|
|
||||||
|
counter: CounterTrigger.configType,
|
||||||
});
|
});
|
||||||
|
|
46
backend/src/plugins/Automod/triggers/counter.ts
Normal file
46
backend/src/plugins/Automod/triggers/counter.ts
Normal file
|
@ -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<CounterTriggerResult>()({
|
||||||
|
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}\``;
|
||||||
|
},
|
||||||
|
});
|
|
@ -13,6 +13,7 @@ import { GuildArchives } from "../../data/GuildArchives";
|
||||||
import { RecentActionType } from "./constants";
|
import { RecentActionType } from "./constants";
|
||||||
import Timeout = NodeJS.Timeout;
|
import Timeout = NodeJS.Timeout;
|
||||||
import { RegExpRunner } from "../../RegExpRunner";
|
import { RegExpRunner } from "../../RegExpRunner";
|
||||||
|
import { CounterEvents } from "../Counters/types";
|
||||||
|
|
||||||
export const Rule = t.type({
|
export const Rule = t.type({
|
||||||
enabled: t.boolean,
|
enabled: t.boolean,
|
||||||
|
@ -86,6 +87,9 @@ export interface AutomodPluginType extends BasePluginType {
|
||||||
|
|
||||||
onMessageCreateFn: any;
|
onMessageCreateFn: any;
|
||||||
onMessageUpdateFn: any;
|
onMessageUpdateFn: any;
|
||||||
|
|
||||||
|
onCounterTrigger: CounterEvents["trigger"];
|
||||||
|
onCounterReverseTrigger: CounterEvents["reverseTrigger"];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -93,6 +97,13 @@ export interface AutomodContext {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
actioned?: boolean;
|
actioned?: boolean;
|
||||||
|
|
||||||
|
counterTrigger?: {
|
||||||
|
name: string;
|
||||||
|
condition: string;
|
||||||
|
channelId: string | null;
|
||||||
|
userId: string | null;
|
||||||
|
reverse: boolean;
|
||||||
|
};
|
||||||
user?: User;
|
user?: User;
|
||||||
message?: SavedMessage;
|
message?: SavedMessage;
|
||||||
member?: Member;
|
member?: Member;
|
||||||
|
|
121
backend/src/plugins/Counters/CountersPlugin.ts
Normal file
121
backend/src/plugins/Counters/CountersPlugin.ts
Normal file
|
@ -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<CountersPluginType> = 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<CountersPluginType>()("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();
|
||||||
|
},
|
||||||
|
});
|
48
backend/src/plugins/Counters/functions/changeCounterValue.ts
Normal file
48
backend/src/plugins/Counters/functions/changeCounterValue.ts
Normal file
|
@ -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<CountersPluginType>,
|
||||||
|
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();
|
||||||
|
}
|
|
@ -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<CountersPluginType>,
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<CountersPluginType>,
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<CountersPluginType>,
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<CountersPluginType>,
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
32
backend/src/plugins/Counters/functions/decayCounter.ts
Normal file
32
backend/src/plugins/Counters/functions/decayCounter.ts
Normal file
|
@ -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<CountersPluginType>,
|
||||||
|
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();
|
||||||
|
}
|
10
backend/src/plugins/Counters/functions/emitCounterEvent.ts
Normal file
10
backend/src/plugins/Counters/functions/emitCounterEvent.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { CounterEvents, CountersPluginType } from "../types";
|
||||||
|
import { GuildPluginData } from "knub";
|
||||||
|
|
||||||
|
export function emitCounterEvent<TEvent extends keyof CounterEvents>(
|
||||||
|
pluginData: GuildPluginData<CountersPluginType>,
|
||||||
|
event: TEvent,
|
||||||
|
...rest: Parameters<CounterEvents[TEvent]>
|
||||||
|
) {
|
||||||
|
return pluginData.state.events.emit(event, ...rest);
|
||||||
|
}
|
31
backend/src/plugins/Counters/functions/initCounterTrigger.ts
Normal file
31
backend/src/plugins/Counters/functions/initCounterTrigger.ts
Normal file
|
@ -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<CountersPluginType>,
|
||||||
|
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);
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { CounterEventEmitter, CountersPluginType } from "../types";
|
||||||
|
import { GuildPluginData } from "knub";
|
||||||
|
|
||||||
|
export function offCounterEvent(
|
||||||
|
pluginData: GuildPluginData<CountersPluginType>,
|
||||||
|
...rest: Parameters<CounterEventEmitter["off"]>
|
||||||
|
) {
|
||||||
|
return pluginData.state.events.off(...rest);
|
||||||
|
}
|
10
backend/src/plugins/Counters/functions/onCounterEvent.ts
Normal file
10
backend/src/plugins/Counters/functions/onCounterEvent.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { CounterEvents, CountersPluginType } from "../types";
|
||||||
|
import { GuildPluginData } from "knub";
|
||||||
|
|
||||||
|
export function onCounterEvent<TEvent extends keyof CounterEvents>(
|
||||||
|
pluginData: GuildPluginData<CountersPluginType>,
|
||||||
|
event: TEvent,
|
||||||
|
listener: CounterEvents[TEvent],
|
||||||
|
) {
|
||||||
|
return pluginData.state.events.on(event, listener);
|
||||||
|
}
|
45
backend/src/plugins/Counters/functions/setCounterValue.ts
Normal file
45
backend/src/plugins/Counters/functions/setCounterValue.ts
Normal file
|
@ -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<CountersPluginType>,
|
||||||
|
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();
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { GuildPluginData } from "knub";
|
||||||
|
import { CountersPluginType } from "../types";
|
||||||
|
import { parseCondition } from "../../../data/GuildCounters";
|
||||||
|
|
||||||
|
export function validateCondition(pluginData: GuildPluginData<CountersPluginType>, condition: string) {
|
||||||
|
const parsed = parseCondition(condition);
|
||||||
|
return parsed != null;
|
||||||
|
}
|
47
backend/src/plugins/Counters/types.ts
Normal file
47
backend/src/plugins/Counters/types.ts
Normal file
|
@ -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<typeof Counter>;
|
||||||
|
|
||||||
|
export const ConfigSchema = t.type({
|
||||||
|
counters: t.record(t.string, Counter),
|
||||||
|
});
|
||||||
|
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
|
||||||
|
|
||||||
|
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<U extends keyof CounterEvents>(event: U, listener: CounterEvents[U]): this;
|
||||||
|
|
||||||
|
emit<U extends keyof CounterEvents>(event: U, ...args: Parameters<CounterEvents[U]>): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CountersPluginType extends BasePluginType {
|
||||||
|
config: TConfigSchema;
|
||||||
|
state: {
|
||||||
|
counters: GuildCounters;
|
||||||
|
counterIds: Record<string, number>;
|
||||||
|
decayTimers: Timeout[];
|
||||||
|
events: CounterEventEmitter;
|
||||||
|
counterTriggersByCounterId: Map<number, Map<number, CounterTrigger>>;
|
||||||
|
};
|
||||||
|
}
|
|
@ -32,6 +32,7 @@ import { CustomEventsPlugin } from "./CustomEvents/CustomEventsPlugin";
|
||||||
import { BotControlPlugin } from "./BotControl/BotControlPlugin";
|
import { BotControlPlugin } from "./BotControl/BotControlPlugin";
|
||||||
import { GuildAccessMonitorPlugin } from "./GuildAccessMonitor/GuildAccessMonitorPlugin";
|
import { GuildAccessMonitorPlugin } from "./GuildAccessMonitor/GuildAccessMonitorPlugin";
|
||||||
import { TimeAndDatePlugin } from "./TimeAndDate/TimeAndDatePlugin";
|
import { TimeAndDatePlugin } from "./TimeAndDate/TimeAndDatePlugin";
|
||||||
|
import { CountersPlugin } from "./Counters/CountersPlugin";
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
export const guildPlugins: Array<ZeppelinGuildPluginBlueprint<any>> = [
|
export const guildPlugins: Array<ZeppelinGuildPluginBlueprint<any>> = [
|
||||||
|
@ -65,6 +66,7 @@ export const guildPlugins: Array<ZeppelinGuildPluginBlueprint<any>> = [
|
||||||
CompanionChannelsPlugin,
|
CompanionChannelsPlugin,
|
||||||
CustomEventsPlugin,
|
CustomEventsPlugin,
|
||||||
TimeAndDatePlugin,
|
TimeAndDatePlugin,
|
||||||
|
CountersPlugin,
|
||||||
];
|
];
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
|
|
Loading…
Add table
Reference in a new issue