robo.js icon indicating copy to clipboard operation
robo.js copied to clipboard

@robojs/cooldown - Cooldowns & Rate-Limiting

Open Pkmmte opened this issue 2 months ago • 3 comments

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.

Pkmmte avatar Oct 06 '25 04:10 Pkmmte

@Pkmmte I would like to work on this issue. Can i be assigned?

matusvasko avatar Oct 06 '25 09:10 matusvasko

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?
  • 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!

sorabhlahoti avatar Oct 06 '25 14:10 sorabhlahoti

@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

Pkmmte avatar Oct 07 '25 07:10 Pkmmte