diff --git a/TypeScript/cooldown.ts b/TypeScript/cooldown.ts new file mode 100644 index 0000000..e3cb267 --- /dev/null +++ b/TypeScript/cooldown.ts @@ -0,0 +1,160 @@ +/** + * @author HighArcs + * @version 1.0.0 + * @description allows you to set cooldowns (or "ratelimits") for commands + * @license null + + * @example + * ```ts + * import { cooldown } from "../plugins/cooldown"; + * import { sernModule, CommandType } from "@sern/handler"; + * export default commandModule({ + * plugins: [cooldown.add( [ ['channel', '1/4'] ] )], // limit to 1 action every 4 seconds per channel + * execute: (ctx) => {} + * }) + * ``` + */ + + import { CommandType, Context, EventPlugin, PluginType } from "@sern/handler"; + import { GuildMember } from "discord.js"; + /** + * actions/seconds + */ + export type CooldownString = `${number}/${number}`; + export interface Cooldown { + location: CooldownLocation; + seconds: number; + actions: number; + } + export enum CooldownLocation { + channel = "channel", + user = "user", + guild = "guild", + } + + export class ExpiryMap extends Map { + public readonly expiry: number; + constructor( + expiry: number = Infinity, + iterable: [K, V][] | ReadonlyMap = [] + ) { + super(iterable); + this.expiry = expiry; + } + + public set(key: K, value: V, expiry: number = this.expiry): this { + super.set(key, value); + if (expiry !== Infinity) setTimeout(() => { + super.delete(key) + }, expiry); + return this; + } + } + + export const map = new ExpiryMap(); + + function parseCooldown( + location: CooldownLocation, + cooldown: CooldownString + ): Cooldown { + const [actions, seconds] = cooldown.split("/").map((s) => Number(s)); + + if ( + !Number.isSafeInteger(actions) || + !Number.isSafeInteger(seconds) || + actions === undefined || + seconds === undefined + ) { + throw new Error(`Invalid cooldown string: ${cooldown}`); + } + + return { + actions, + seconds, + location, + }; + } + + function getPropertyForLocation(context: Context, location: CooldownLocation) { + switch (location) { + case CooldownLocation.channel: + return context.channel!.id; + case CooldownLocation.user: + if (!context.member || !(context.member instanceof GuildMember)) { + throw new Error("context.member is not a GuildMember"); + } + return context.member.id; + case CooldownLocation.guild: + return context.guildId; + } + } + + export interface RecievedCooldown { + location: CooldownLocation; + actions: number; + maxActions: number; + seconds: number; + context: Context; + } + type CooldownResponse = (cooldown: RecievedCooldown) => any; + + function add( + items: Array< + | [CooldownLocation | keyof typeof CooldownLocation, CooldownString] + | Cooldown + >, + message?: CooldownResponse + ): EventPlugin { + const raw = items.map((c) => { + if (!Array.isArray(c)) return c; + return parseCooldown(c[0] as CooldownLocation, c[1]); + }) as Array; + + return { + name: "cooldown", + description: "limits user/channel/guild actions", + type: PluginType.Event, + async execute([context], controller) { + for (const { location, actions, seconds } of raw) { + const id = getPropertyForLocation(context, location); + const cooldown = map.get(id); + + if (!cooldown) { + map.set(id, 1, seconds * 1000); + continue; + } + + if (cooldown >= actions) { + if (message) { + await message({ + location, + actions: cooldown, + maxActions: actions, + seconds, + context, + }); + } + return controller.stop(); + } + + map.set(id, cooldown + 1, seconds * 1000); + } + return controller.next(); + }, + }; + } + + type Location = (value: CooldownString) => ReturnType; + + const locations: Record = { + [CooldownLocation.channel]: (value) => + add([[CooldownLocation.channel, value]]), + [CooldownLocation.user]: (value) => add([[CooldownLocation.user, value]]), + [CooldownLocation.guild]: (value) => add([[CooldownLocation.guild, value]]), + }; + + export const cooldown = { + add, + locations, + map, + }; \ No newline at end of file