Cooldowns
Stop someone spamming a command by limiting how often they can run it with Cooldown(), choose who shares a window, and keep windows across restarts.
A command that calls a paid API or posts in a channel needs a limit, or one person can spam it dozens of times a minute. By hand, that's a map of last-use times in every handler that needs one, plus a reply telling the caller when to try again.
Cooldown is that limit as a gate. It allows a set number of uses per window and refuses once they're used up, with a card that shows when the caller can try again.
import {
Cooldown,
Gated,
SlashHandler,
SlashRoute
} from '@seedcord/gateway';
@Gated(Cooldown('30m'))
@SlashRoute('feed')
export class Feed extends SlashHandler<'feed'> {
public async execute(): Promise<void> {
await this.reply('Fed.');
}
}Adding options
Start with a duration alone, then add options as the command needs them. Each line below adds one option to the line above it.
// one use per caller every 30 seconds
Cooldown(30);
// three uses per caller every ten minutes
Cooldown('10m', { limit: 3 });
// three uses per server every ten minutes
Cooldown('10m', { limit: 3, per: 'guild' });The sections below cover each option in detail.
The duration
You pass a duration literal as a string, or a number of seconds.
Cooldown('30m'); // thirty minutes
Cooldown(30); // thirty secondsA duration literal is digits followed by one lowercase unit.
| unit | means |
|---|---|
ms | milliseconds |
s | seconds |
m | minutes |
h | hours |
d | days |
Warning
The type accepts any number in front of the unit. The parser accepts whole digits alone. So this compiles, then throws as the module loads.
Cooldown('1.5h');That throw stops your bot from starting. The error's code is GateInvalidCooldownDuration. Write Cooldown('90m') instead.
Everything else the parser rejects throws the same way.
- an uppercase unit, as in
30M - a bare number in a string, as in
'30' - a zero duration, as in
0s
Several uses per window
You set how many uses fit in one window with limit. It defaults to 1.
Cooldown('10m', { limit: 3 });The window is a sliding window, so each use counts for ten minutes after it happens. If someone runs the command at 12:00, 12:02, and 12:05, all three pass. A fourth run at 12:06 is refused. At 12:10 the 12:00 use expires, so the next run after that passes.
Who shares a window
If you leave per out, every caller gets their own window. Pass 'guild' or 'channel' to widen it.
Cooldown('10m', { per: '- user
- guild
- channel
per | one window per | falls back to |
|---|---|---|
'user', the default | caller | one shared window when the source doesn't carry a user |
'guild' | server | one shared window when the source doesn't carry a server |
'channel' | channel | one shared window when the source doesn't carry a channel |
Cooldown('1h', { per: 'guild' });With per: 'guild', every caller outside a server shares one global window. If one person uses the command in a DM, they can use up the window for every other DM caller. Pair it with GuildOnly when your command should only run in a server anyway.
If two handlers use the same settings, they still get separate windows, since the key includes the route. If one handler has two Cooldown calls with different settings, their windows stay separate too.
Replacing the refusal
notice takes a Notice class you write, and seedcord constructs it with resetAt. That's the time the caller can use the command again, as an EpochMs number of milliseconds since 1970.
import {
Cooldown,
Notice,
toEpochSeconds
} from '@seedcord/gateway';
import type { EpochMs, ReplyResponse } from '@seedcord/gateway';
declare const components: ReplyResponse['components'];
class SlowDown extends Notice {
public constructor(resetAt: EpochMs) {
super(
`Slow down. Try again <t:${toEpochSeconds(resetAt)}:R>.`
);
}
public override render(): ReplyResponse {
return { components };
}
}
Cooldown('10s', { per: 'channel', notice: SlowDown });Discord's <t:...:R> markup (opens in a new tab) takes seconds. toEpochSeconds converts a millisecond timestamp to them. The default card already prints a relative time.
Putting it together
This handler sets limit and per together. Each server gets three feeds every ten minutes. When a server has used all three, the refusal says when the caller can try again.
import {
Cooldown,
Gated,
SlashHandler,
SlashRoute
} from '@seedcord/gateway';
@Gated(Cooldown('10m', { limit: 3, per: 'guild' }))
@SlashRoute('feed')
export class Feed extends SlashHandler<'feed'> {
public async execute(): Promise<void> {
await this.reply('Fed.');
}
}When a use is charged
Cooldown is an effect gate. It reads the window during the check and records the use only after every gate on the handler has passed. If a later gate refuses, the commit never runs, so the caller keeps their use.
Note
If two requests for the same key arrive at the same moment, both can find room in the window before either records its use. A tight burst can go over limit that way. Every effect gate has this gap between its check and its commit.
Cooldown doesn't guard against it, since bursts that tight are rare. If your command can't allow even one extra use, write your own effect gate. In its commit, call ctx.core.rateLimiter.charge, which records a use only when the window still has room. Throw when result.limited comes back true, and put that gate first so no other commit runs before it.
Keeping windows across restarts
core.rateLimiter holds the cooldown windows and counts them in memory by default. A restart clears every cooldown, and on a serverless deploy each running copy keeps its own counts. A kv driver over a file or a database keeps the windows through both.