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:
parent
ec37cf27a2
commit
c3407e2d5d
29 changed files with 1387 additions and 3 deletions
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>>;
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue