-
-
Notifications
You must be signed in to change notification settings - Fork 37
Description
Goal
Provide an easy, flexible way to throttle slash command usage in Robo.js using middleware enforcement, the States API (with namespaces and persist: true), and simple, configurable per-command usage limits. Include optional HTTP endpoints and an imperative API so teams can build dashboards later.
Objectives
-
Drop-in enforcement of cooldowns for slash commands via middleware.
-
Configurable rules by command/module/role/user/channel with scopes (
user,guild,channel,user+guild,custom). -
Simple strategies:
- Fixed window (e.g., max 1 execution per 30s).
- Sliding window (e.g., max 5 executions per 60s).
-
Good UX: human messages with remaining time; works cleanly with Robo’s Sage replies.
-
Persistence: use States API with namespaces and
persist: truefor both rules and counters. -
Optional HTTP API (file-based via
@robojs/server) to GET/PUT guild rules and inspect counters. -
Imperative API for programmatic checks and settings.
-
Docs & examples first; tests encouraged but not required.
Concepts
- Rule: A condition like “
/aicommand, scope=user+guild, fixed window 30s”. - Scope: The identity of a bucket (user, guild, channel, user+guild, or custom key function).
- Bucket: A bucket is the logical container used to count how many times a slash command was run in a given window. Each bucket is identified by a key derived from the chosen scope (e.g.,
guildId:userId:recordKey). Buckets store timestamps or counters in the States API so the plugin can decide when a cooldown should block further actions. - Strategy:
fixedorsliding. - Namespaces: States API namespacing for isolation, e.g.,
['cooldown', 'buckets', guildId].
High-Level Architecture
Components
-
Middleware (
src/middleware/xx-cooldown.ts)- Runs before every slash command.
- Resolves matching rule(s) for the current
record.key/record.module, user, roles, channel, etc. - Calculates bucket key by configured scope.
- Reads & updates counters via States API with
persist: true. - If blocked, abort and return a Sage-friendly message (string or
{ content, ephemeral }).
-
State Storage (States API)
- Rules state: persisted per-guild (admin-configured), under namespace
['cooldown','rules', guildId]. - Buckets state: per-guild counters under
['cooldown','buckets', guildId].
- Rules state: persisted per-guild (admin-configured), under namespace
-
Config Loader
- Reads
/config/plugins/@robojs/cooldown.mjs. - Merges defaults with per-guild overrides from States.
- Reads
-
Optional HTTP API (requires
@robojs/server)- Routes under
/src/api/cooldown/**for reading/updating rules and inspecting counters.
- Routes under
-
Imperative API
Cooldown.check()/Cooldown.consume()for programmatic control.Cooldown.setRule()/getRules()/removeRule()for persistent settings.
Data Model (States API)
Namespaces
- Rules:
['cooldown', 'rules', guildId] - Buckets:
['cooldown', 'buckets', guildId]
All state calls include
{ namespace, persist: true }so values survive restarts.
Bucket Document
type BucketDoc = {
strategy: 'fixed' | 'sliding'
windowMs: number
max: number // sliding: max allowed per window; fixed: usually 1
history?: number[] // sorted timestamps (ms) within window (bounded)
lastAt?: number // for fixed window (anchor timestamp)
}Rule Document
type Rule = {
id?: string
enabled?: boolean
where?: {
command?: string | RegExp
module?: string | RegExp
roles?: string[]
users?: string[]
channels?: string[]
}
scope: 'user'|'guild'|'channel'|'user+guild'|'custom'
customKey?: (ctx) => string
strategy: 'fixed' | 'sliding'
window?: string
max?: number
bypass?: { roles?: string[]; users?: string[] }
message?: string
ephemeral?: boolean
}Plugin Configuration
Path: /config/plugins/@robojs/cooldown.mjs
export default {
default: {
strategy: 'fixed',
window: '5s',
scope: 'user+guild',
message: 'Cooldown! Try again in {remaining}.',
ephemeral: true
},
rules: [
{ where: { command: 'ai' }, strategy: 'fixed', window: '30s' },
{ where: { module: /^economy/ }, strategy: 'sliding', window: '60s', max: 5 },
{ where: { command: /^admin\// }, bypass: { roles: ['Admin','Moderator'] } },
{ where: { channels: ['1234567890'] }, scope: 'channel', strategy: 'fixed', window: '10s' }
]
}Enforcement Flow (Middleware)
-
Build context from
recordand interaction:userId,guildId,channelId, roles,record.key,record.module. -
Resolve most-specific rule that matches
where. -
If bypass matches, allow.
-
Compute bucket key from
scope. -
Branch by strategy:
- fixed: if
now - lastAt < windowMs→ block; else setlastAt = now. - sliding: purge timestamps older than
now - windowMs, ifhistory.length >= max→ block; else pushnowand persist.
- fixed: if
-
On block → return
{ abort: true, result: reply }. Sage Mode sends it. -
On allow → persist updates and continue.
Optional HTTP API (requires @robojs/server)
Example routes:
/src/api/cooldown/[guildId]/rules.js
/src/api/cooldown/[guildId]/rules/[id].js
/src/api/cooldown/[guildId]/buckets.js
/src/api/cooldown/[guildId]/reset.js
Imperative API
import { Cooldown } from '@robojs/cooldown'
await Cooldown.setRule(guildId, rule: Rule)
await Cooldown.removeRule(guildId, id: string)
const rules = await Cooldown.getRules(guildId)
const res = await Cooldown.check(input)
const res2 = await Cooldown.consume(input)
await Cooldown.clearBuckets(guildId, filter)Commands
/cooldown info— Show effective rules and current status for a command./cooldown set— Admin helper to upsert a rule./cooldown clear— Clear counters (optionally filter by command/user).
Scopes
- user (
userId) - guild (
guildId) - channel (
channelId) - user+guild
- custom
Edge Cases & Notes
- DMs:
guildIdis null → either bypass or treat as its own scope. - Bypass: if any bypass role/user matches, skip enforcement.
- Timestamp arrays: keep shallow.
- Time parsing: accept
5s,30s,2m,1h.
Acceptance Criteria
- Installing the plugin and enabling middleware enforces default cooldowns for slash commands.
- Developers can override behavior with a config file and per-guild rules persisted via States API.
- Supports at least fixed and sliding strategies.
- Optional HTTP routes work when
@robojs/serveris installed (no auth). - Documentation includes install, config, examples, and maintenance commands.
Suggested Repo Layout
packages/
@robojs/cooldown/
src/
middleware/
01-cooldown.ts
api/
commands/
cooldown/
info.ts
set.ts
clear.ts
lib/
time.ts
rules.ts
storage.ts
strategies.ts
cooldown.ts
README.md
package.json
tsconfig.json
Implementation Example: Minimal Middleware
// src/middleware/01-cooldown.ts
import { getState } from 'robo.js'
import { parseWindow, matchRule, buildKey, humanize } from '../lib/time-and-utils'
export default async (data) => {
if (data.record.type !== 'command') return
const [interaction] = data.payload
const guildId = interaction.guildId
const userId = interaction.user.id
const channelId = interaction.channelId
const rule = await matchRule({ record: data.record, interaction })
if (!rule) return
const scopeKey = buildKey(rule.scope, { userId, guildId, channelId, recordKey: data.record.key })
const buckets = getState('cooldown:buckets', { namespace: [guildId ?? 'dm'], persist: true })
const now = Date.now()
const windowMs = parseWindow(rule.window ?? '5s')
if (rule.strategy === 'fixed') {
const bucket = (await buckets.get(scopeKey)) || { lastAt: 0 }
const remaining = bucket.lastAt && bucket.lastAt + windowMs - now
if (remaining && remaining > 0) {
return { abort: true, result: `Try again in ${humanize(remaining)}.` }
}
await buckets.set(scopeKey, { lastAt: now })
return
}
if (rule.strategy === 'sliding') {
const bucket = (await buckets.get(scopeKey)) || { history: [] }
const cutoff = now - windowMs
bucket.history = (bucket.history || []).filter(t => t >= cutoff)
const max = rule.max ?? 1
if (bucket.history.length >= max) {
const oldest = bucket.history[0]
const remaining = windowMs - (now - oldest)
return { abort: true, result: `Try again in ${humanize(remaining)}.` }
}
bucket.history.push(now)
await buckets.set(scopeKey, bucket)
}
}Contributor Notes
- Keep code small and composable; prefer pure helpers in
lib/. - Tests are encouraged but not required.
- Aim for clear docs and runnable examples.