Skip to content

Commit

Permalink
feat: cooldown
Browse files Browse the repository at this point in the history
  • Loading branch information
socram03 committed Sep 1, 2024
1 parent 7a72784 commit 8e43fc9
Show file tree
Hide file tree
Showing 10 changed files with 423 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/cooldown/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A custom implementation of [limiter](https://github.com/jhurliman/node-rate-limiter) for cooldown in seyfert
29 changes: 29 additions & 0 deletions packages/cooldown/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@slipher/cooldown",
"version": "0.0.1",
"private": false,
"license": "MIT",

"publishConfig": {
"access": "public"
},
"files": [
"lib/**"
],
"main": "./lib/index.js",
"module": "./lib/index.js",
"types": "./lib/index.d.ts",
"scripts": {
"dev": "tsc --watch",
"build": "tsc",
"lint": "biome lint --write ./src",
"format": "biome format --write ./src"
},
"devDependencies": {
"@types/node": "^22.4.0",
"typescript": "^5.5.4"
},
"peerDependencies": {
"seyfert": "^2.0.0"
}
}
24 changes: 24 additions & 0 deletions packages/cooldown/src/clock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// https://github.com/jhurliman/node-rate-limiter/blob/main/src/clock.ts

// generate timestamp or delta
// see http://nodejs.org/api/process.html#process_process_hrtime
function hrtime(previousTimestamp?: [number, number]): [number, number] {
const clocktime = performance.now() * 1e-3;
let seconds = Math.floor(clocktime);
let nanoseconds = Math.floor((clocktime % 1) * 1e9);
if (previousTimestamp !== undefined) {
seconds -= previousTimestamp[0];
nanoseconds -= previousTimestamp[1];
if (nanoseconds < 0) {
seconds--;
nanoseconds += 1e9;
}
}
return [seconds, nanoseconds];
}

// The current timestamp in whole milliseconds
export function getMilliseconds(): number {
const [seconds, nanoseconds] = hrtime();
return seconds * 1e3 + Math.floor(nanoseconds / 1e6);
}
11 changes: 11 additions & 0 deletions packages/cooldown/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { CooldownProps } from './manager';

export * from './manager';
export * from './resource';

export function Cooldown(props: CooldownProps) {
return <T extends { new (...args: any[]): {} }>(target: T) =>
class extends target {
cooldown = props;
};
}
150 changes: 150 additions & 0 deletions packages/cooldown/src/manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import type { UsingClient } from 'seyfert';
import { type CooldownData, type CooldownDataInsert, Cooldowns, type CooldownType } from './resource';
import { getMilliseconds } from './clock';

export class CooldownManager {
resource: Cooldowns;
constructor(readonly client: UsingClient) {
this.resource = new Cooldowns(client.cache, client);
}

/**
* Get the cooldown data for a command
* @param name - The name of the command
* @returns The cooldown data for the command
*/
getData(name: string): CooldownProps | undefined {
return this.client.commands?.values.find(x => x.name === name)?.cooldown;
}

/**
* Check if a user has a cooldown
* @param name - The name of the command
* @param target - The target of the cooldown
* @returns Whether the user has a cooldown
*/
has(name: string, target: string) {
const data = this.getData(name);
if (!data) return false;

const cooldown = this.resource.get(`${name}:${data.type}:${target}`);
if (!cooldown) {
this.set(name, target, { type: data.type, interval: data.interval, remaining: data.refill - data.tokens });
return false;
}

const remaining = cooldown.remaining - data.tokens;

if (remaining <= 0) return true;
return false;
}

/**
* Use a cooldown
* @param name - The name of the command
* @param target - The target of the cooldown
* @param tokens - The number of tokens to use
* @returns The remaining cooldown
*/
use(name: string, target: string, tokens?: number) {
const data = this.getData(name);
if (!data) return;

const cooldown = this.resource.get(`${name}:${data.type}:${target}`);
if (!cooldown) {
this.set(name, target, {
type: data.type,
interval: data.interval,
remaining: data.refill - (tokens ?? data.tokens),
});
return true;
}

const drip = this.drip(name, target, data, cooldown);

if (drip.remaining <= 0) return false;

return true;
}

/**
* Refill the cooldown
* @param name - The name of the command
* @param target - The target of the cooldown
* @returns Whether the cooldown was refilled
*/
refill(name: string, target: string, tokens?: number) {
const data = this.getData(name);
if (!data) return false;

const refill = tokens ?? data.refill;

const cooldown = this.resource.get(`${name}:${data.type}:${target}`);
if (!cooldown) {
this.set(name, target, {
type: data.type,
interval: data.interval,
remaining: refill,
});
return true;
}

this.set(name, target, { type: data.type, interval: data.interval, remaining: refill });
return true;
}

/**
* Set the cooldown data for a command
* @param name - The name of the command
* @param target - The target of the cooldown
* @param data - The cooldown data to set
*/
set(name: string, target: string, data: CooldownDataInsert & { type: `${CooldownType}` }) {
this.resource.set(`${name}:${data.type}:${target}`, data);
}

/**
* Drip the cooldown
* @param name - The name of the command
* @param target - The target of the cooldown
* @param props - The cooldown properties
* @param data - The cooldown data
* @returns The remaining cooldown
*/
drip(name: string, target: string, props: CooldownProps, data: CooldownData) {
const now = getMilliseconds();
const deltaMS = Math.max(now - data.lastDrip, 0);
data.lastDrip = now;

const dripAmount = deltaMS * (props.refill / props.interval);
data.remaining = Math.min(data.remaining + dripAmount, props.refill);
const result = { type: props.type, interval: props.interval, remaining: data.remaining };
this.set(name, target, result);
return result;
}
}

export interface CooldownProps {
/** target type */
type: `${CooldownType}`;
/** interval in ms */
interval: number;
/** refill amount */
refill: number;
/** tokens to use */
tokens: number;
/** byPass users */
byPass?: string[];
}

declare module 'seyfert' {
interface Command {
cooldown?: CooldownProps;
}
interface SubCommand {
cooldown?: CooldownProps;
}
interface ContextMenuCommand {
cooldown?: CooldownProps;
}
}
28 changes: 28 additions & 0 deletions packages/cooldown/src/resource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { BaseResource } from "seyfert/lib/cache/resources/default/base";
import { getMilliseconds } from "./clock";

export interface CooldownData {
remaining: number;
interval: number;
lastDrip: number;
}

export type CooldownDataInsert = Omit<CooldownData, 'lastDrip'>;

export enum CooldownType {
User = 'user',
Guild = 'guild',
Channel = 'channel',
}

export class Cooldowns extends BaseResource<CooldownData> {
namespace = 'cooldowns';

filter(_data: CooldownData, _id: string): boolean {
return true;
}

override set(id: string, data: CooldownDataInsert){
return super.set(id, {...data, lastDrip: getMilliseconds()});
}
}
94 changes: 94 additions & 0 deletions packages/cooldown/test/manager.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
const { strict: assert } = require("node:assert/strict");
const { test, describe, beforeEach } = require('node:test');
const { Client, Cache, MemoryAdapter, Logger } = require('seyfert');
const { CommandHandler } = require('seyfert/lib/commands/handler.js');
const { CooldownManager } = require('../lib/manager.js');
const { CooldownType } = require('../lib/resource.js');


describe('CooldownManager', async () => {
let client;
let cooldownManager;

beforeEach(() => {
client = new Client();

const handler = new CommandHandler(new Logger({ active: true }), client);
handler.values = [
// @ts-expect-error
{
name: 'testCommand',
cooldown: {
type: CooldownType.User,
interval: 1000,
refill: 3,
tokens: 1
}
}
]

client.commands = handler;


client.cache = new Cache(0, new MemoryAdapter(), {}, client);
cooldownManager = new CooldownManager(client);
client.cache.cooldown = cooldownManager.resource;
});

await test('getData should return cooldown data for a command', () => {
const data = cooldownManager.getData('testCommand');
assert.deepEqual(data, {
type: CooldownType.User,
interval: 1000,
refill: 3,
tokens: 1
});
});

await test('getData should return undefined for non-existent command', () => {
const data = cooldownManager.getData('nonExistentCommand');
assert.equal(data, undefined);
});

await test('has should return false for a new cooldown', () => {
const result = cooldownManager.has('testCommand', 'user1');
assert.equal(result, false);
});

await test('has should return true when cooldown is active', () => {
cooldownManager.use('testCommand', 'user1', 1000);
const result = cooldownManager.has('testCommand', 'user1');
assert.equal(result, true);
});

await test('use should set cooldown when used for the first time', () => {
const result = cooldownManager.use('testCommand', 'user1');
assert.equal(result, true);
});

await test('use should return true when cooldown is active', () => {
cooldownManager.use('testCommand', 'user1');
const result = cooldownManager.use('testCommand', 'user1');
assert.equal(result, true);
});

await test('refill should refill the cooldown', () => {
cooldownManager.use('testCommand', 'user1');
const result = cooldownManager.refill('testCommand', 'user1');
assert.equal(result, true);
assert.equal(cooldownManager.has('testCommand', 'user1'), false);
});

await test('drip should drip the cooldown over time', async () => {
cooldownManager.use('testCommand', 'user1');

// Simulate time passing
await new Promise(resolve => setTimeout(resolve, 1500)); // Half the interval

const data = cooldownManager.resource.get('testCommand:user:user1');
const props = cooldownManager.getData('testCommand');
const result = cooldownManager.drip('testCommand', 'user1', props, data);
assert.ok(result.remaining === 3);
});
});

38 changes: 38 additions & 0 deletions packages/cooldown/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"compilerOptions": {
"module": "CommonJS",
"target": "ESNext",
"lib": [
"ESNext",
"WebWorker"
],
"moduleResolution": "node",
"declaration": true,
"sourceMap": false,
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"preserveConstEnums": true,
/* Type Checking */
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"noErrorTruncation": true,
"outDir": "./lib",
"stripInternal": true,
},
"exclude": [
"**/lib",
"**/__test__"
],
"include": [
"src"
]
}
Loading

0 comments on commit 8e43fc9

Please sign in to comment.