@robojs/cooldown - Cooldowns & Rate-Limiting
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.
@Pkmmte I would like to work on this issue. Can i be assigned?
Hi team! I've opened PR #453 that sets up the initial structure and documentation for the @robojs/cooldown plugin described here. Before I proceed with the full implementation, could you please confirm a couple of details?
-
Assignment and scope
- Is it okay to be assigned to this issue and continue implementation on the same branch/PR?
- Do you prefer the full implementation in this PR, or should I split it into smaller PRs (middleware + core, then commands + API)?
-
Defaults and behavior
- Default strategy/scope: stick with strategy='fixed', window='5s', scope='user+guild' as specified here, or any preference to change defaults?
- DMs: should DM interactions be bypassed or treated as their own scope (e.g., guildId='dm')?
-
Storage and namespaces
- I'll use States API with persist: true and namespaces:
- ['cooldown', 'rules', guildId]
- ['cooldown', 'buckets', guildId]
- Any adjustments to these namespace keys?
- I'll use States API with persist: true and namespaces:
-
HTTP API
- I plan to add file-based routes under /src/api/cooldown/[guildId]/* as outlined. Any naming or shape changes preferred (e.g., split rules CRUD vs. single endpoint)?
-
Vercel deploys
- The PR currently shows "Authorization required to deploy." If previews are desired for this repo, could someone authorize/approve the Vercel deployment for PRs from forks?
Next steps I'll push (unless you advise otherwise):
- Core: rule matching, scope key builder, time parsing, strategies (fixed + sliding), storage wrapper
- Middleware: 01-cooldown.ts with abort flow and Sage-friendly replies
- Commands: /cooldown info, set, clear
- API: rules list/upsert, buckets inspect, reset
- Docs updates and examples
Happy to adapt to any conventions or preferences you have. Thanks!
@matusvasko Hi, it seems this one already has someone started on it, so I've approved your other request: https://github.com/Wave-Play/robo.js/issues/452 👍
@sorabhlahoti Thanks for helping us out this Hacktoberfest!
Is it okay to be assigned to this issue and continue implementation on the same branch/PR?
Yup, that's alright! Just please make sure to title your PR as [DRAFT] until its ready.
Do you prefer the full implementation in this PR, or should I split it into smaller PRs (middleware + core, then commands + API)?
We'd rather it be one PR, but feel free to ping either me or @Nazeofel if you have any questions along the way. Just to make sure you're on the right track! You can also reach us directly on our Discord.
Default strategy/scope: stick with strategy='fixed', window='5s', scope='user+guild' as specified here, or any preference to change defaults?
The ones specified are preferred, but we're open to another solution if you think that's best.
DMs: should DM interactions be bypassed or treated as their own scope (e.g., guildId='dm')?
Good question! They should be their own scope.
Any adjustments to these namespace keys?
Those look good! 👍
I plan to add file-based routes under /src/api/cooldown/[guildId]/* as outlined. Any naming or shape changes preferred (e.g., split rules CRUD vs. single endpoint)?
Prefer split CRUD routes would be preferred.
The PR currently shows "Authorization required to deploy." If previews are desired for this repo, could someone authorize/approve the Vercel deployment for PRs from forks?
Don't worry about that. That's an issue on our Vercel configuration. Previews are not necessary for this PR.
You may find the following resources useful:\
- https://github.com/Wave-Play/robo.js/blob/main/CONTRIBUTING.md
- https://robojs.dev/plugins/create