Skip to content

@robojs/cooldown - Cooldowns & Rate-Limiting #448

@Pkmmte

Description

@Pkmmte

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

  1. Drop-in enforcement of cooldowns for slash commands via middleware.

  2. Configurable rules by command/module/role/user/channel with scopes (user, guild, channel, user+guild, custom).

  3. Simple strategies:

    • Fixed window (e.g., max 1 execution per 30s).
    • Sliding window (e.g., max 5 executions per 60s).
  4. Good UX: human messages with remaining time; works cleanly with Robo’s Sage replies.

  5. Persistence: use States API with namespaces and persist: true for both rules and counters.

  6. Optional HTTP API (file-based via @robojs/server) to GET/PUT guild rules and inspect counters.

  7. Imperative API for programmatic checks and settings.

  8. Docs & examples first; tests encouraged but not required.

Concepts

  • Rule: A condition like “/ai command, 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: fixed or sliding.
  • Namespaces: States API namespacing for isolation, e.g., ['cooldown', 'buckets', guildId].

High-Level Architecture

Components

  1. 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 }).
  2. 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].
  3. Config Loader

    • Reads /config/plugins/@robojs/cooldown.mjs.
    • Merges defaults with per-guild overrides from States.
  4. Optional HTTP API (requires @robojs/server)

    • Routes under /src/api/cooldown/** for reading/updating rules and inspecting counters.
  5. 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)

  1. Build context from record and interaction: userId, guildId, channelId, roles, record.key, record.module.

  2. Resolve most-specific rule that matches where.

  3. If bypass matches, allow.

  4. Compute bucket key from scope.

  5. Branch by strategy:

    • fixed: if now - lastAt < windowMs → block; else set lastAt = now.
    • sliding: purge timestamps older than now - windowMs, if history.length >= max → block; else push now and persist.
  6. On block → return { abort: true, result: reply }. Sage Mode sends it.

  7. 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: guildId is 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/server is 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.

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions