3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-05-10 12:25:02 +00:00

Counters v0.9

Includes automod trigger/action. No user-facing commands yet.
This commit is contained in:
Dragory 2021-02-13 17:29:10 +02:00
parent ec37cf27a2
commit c3407e2d5d
No known key found for this signature in database
GPG key ID: 5F387BA66DF8AAC1
29 changed files with 1387 additions and 3 deletions

View 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();
},
});

View 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();
}

View file

@ -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,
);
}
}

View file

@ -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,
);
}
}

View file

@ -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,
);
}
}

View file

@ -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,
);
}
}

View 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();
}

View 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);
}

View 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);
}

View file

@ -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);
}

View 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);
}

View 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();
}

View file

@ -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;
}

View 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>>;
};
}