@seedcord/core 0.1.0-next.1 → 0.1.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,5 +1,6 @@
1
- import { c as RowComponent, l as BuilderType, n as CustomId, s as BuilderComponent, u as RowType } from "./CustomId-BJF6mbJs.mjs";
2
- import { RenderContext, ReplyResponse } from "@seedcord/types";
1
+ import { C as BuilderComponent, E as RowType, S as RegisterCommand, T as BuilderType, _ as RequiredOf, b as CoreBase, d as JoinNames, f as TwoOrMore, g as GateContextBase, h as Gate, m as EffectGate, n as CustomId, p as Notice, s as GateNoticeOptions, u as IntersectRequired, v as defineEffectGate, w as RowComponent, y as defineGate } from "./CustomId-Ddwg48VY.mjs";
2
+ import { ValidDuration } from "@seedcord/utils";
3
+ import { EpochMs, RenderContext, ReplyResponse } from "@seedcord/types";
3
4
 
4
5
  //#region src/registries/ContextMenuRegistry.d.ts
5
6
  /**
@@ -59,71 +60,230 @@ interface SlashOption {
59
60
  */
60
61
  interface SlashOptionRegistry {}
61
62
  //#endregion
62
- //#region src/stops/Notice.d.ts
63
+ //#region src/gates/combinators.d.ts
63
64
  /**
64
- * Base class for a user-facing refusal or a reported fault.
65
+ * Runs each gate in order and refuses on the first refusal. Takes two or more arms. The required
66
+ * context is the intersection of the arms, so an event-only and an interaction-only gate cannot be
67
+ * combined.
65
68
  *
66
- * Throw a `Notice` to stop a handler and reply to the user. The framework catches it at the controller
67
- * boundary and renders {@link Notice.render}, which always decides what the user sees. With `report`
68
- * false that render is all that happens. With `report` true the framework also logs the fault and
69
- * publishes it to the `handledException` bus. A raw, non-Notice throw shows the generic message.
69
+ * @typeParam Gates - The tuple of two or more gate arms, run left to right.
70
+ * @param gates - The gate arms to run in order, all refused on the first refusal.
71
+ *
72
+ * @see {@link or}
70
73
  *
71
74
  * @example
72
75
  * ```ts
73
- * import { Notice, BuilderComponent, type RenderContext, type ReplyResponse } from 'seedcord';
74
- * import { TextDisplayBuilder } from '@discordjs/builders';
76
+ * import { PermissionFlagsBits } from 'discord.js';
75
77
  *
76
- * // reading `.component` applies the configured bot color to the container accent
77
- * class TooPoorCard extends BuilderComponent<'container'> {
78
- * constructor(balance: number) {
79
- * super('container');
80
- * this.instance.addTextDisplayComponents(
81
- * new TextDisplayBuilder().setContent(`### Insufficient balance\nYou need more than ${balance} coins.`)
82
- * );
78
+ * \@Gated(and(GuildOnly(), RequirePermissions([PermissionFlagsBits.BanMembers])))
79
+ * \@SlashRoute('ban')
80
+ * class BanHandler extends SlashHandler<'ban'> {
81
+ * async execute() {
82
+ * // ...
83
83
  * }
84
84
  * }
85
+ * ```
86
+ */
87
+ declare function and<Gates extends TwoOrMore<Gate<GateContextBase>>>(...gates: Gates): Gate<IntersectRequired<Gates> & GateContextBase, JoinNames<Gates, ' & '>>;
88
+ interface OrOptions {
89
+ fail: Notice | ((ctx: GateContextBase) => Notice);
90
+ }
91
+ /**
92
+ * Runs each gate in order and passes on the first arm that passes. Takes two or more arms. The
93
+ * required context is the union of the arms, so a handler that matches any one arm fits. When every
94
+ * arm refuses it throws the trailing {@link OrOptions} `fail` if given, else an auto list of the
95
+ * arms derived from the summary field, else a default refusal. The trailing options object does not
96
+ * count as an arm.
97
+ *
98
+ * @typeParam Gates - The tuple of two or more gate arms, tried left to right.
99
+ * @param gates - The gate arms to try in order, with an optional trailing {@link OrOptions} object.
85
100
  *
86
- * class TooPoor extends Notice {
87
- * constructor(private readonly balance: number) {
88
- * super(`balance ${balance} is below the cost`);
101
+ * @see {@link and}
102
+ *
103
+ * @example
104
+ * ```ts
105
+ * \@Gated(or(OwnerOnly(), RequireRole('123456789012345678')))
106
+ * \@SlashRoute('admin')
107
+ * class AdminHandler extends SlashHandler<'admin'> {
108
+ * async execute() {
109
+ * // ...
89
110
  * }
111
+ * }
112
+ * ```
113
+ */
114
+ declare function or<Gates extends TwoOrMore<Gate<GateContextBase>>>(...gates: Gates): Gate<RequiredOf<Gates[number]>, JoinNames<Gates, ' | '>>;
115
+ /**
116
+ * @example
117
+ * ```ts
118
+ * // custom refusal when every arm refuses
119
+ * or(OwnerOnly(), RequireRole('123456789012345678'), { fail: new NotAllowedNotice() });
120
+ * ```
121
+ */
122
+ declare function or<Gates extends TwoOrMore<Gate<GateContextBase>>>(...args: [...Gates, OrOptions]): Gate<RequiredOf<Gates[number]>, JoinNames<Gates, ' | '>>;
123
+ //#endregion
124
+ //#region src/gates/catalog/access.d.ts
125
+ /**
126
+ * Passes only for a user id listed in `config.ownerIds`, else refuses.
127
+ *
128
+ * Agnostic, so it attaches to interaction and event handlers alike. With no `ownerIds` configured it refuses every caller. Pass {@link GateNoticeOptions} to reword or replace the refusal.
129
+ *
130
+ * @param options - Reword the default refusal with `message`, or replace it with `notice`.
90
131
  *
91
- * render(_ctx: RenderContext): ReplyResponse {
92
- * return { components: [new TooPoorCard(this.balance).component] };
132
+ * @see the `@Gated` decorator from `seedcord`
133
+ *
134
+ * @example
135
+ * ```ts
136
+ * \@Gated(OwnerOnly())
137
+ * \@SlashRoute('shutdown')
138
+ * class ShutdownHandler extends SlashHandler<'shutdown'> {
139
+ * async execute() {
140
+ * // ...
93
141
  * }
94
142
  * }
143
+ * ```
144
+ *
145
+ * @example
146
+ * ```ts
147
+ * // reword the refusal, keeping the embed styling
148
+ * OwnerOnly({ message: 'Owners only.' });
149
+ * ```
150
+ */
151
+ declare function OwnerOnly(options?: GateNoticeOptions): Gate<GateContextBase, 'OwnerOnly'>;
152
+ /**
153
+ * Passes inside a guild, else refuses.
95
154
  *
96
- * // in a handler, throwing stops the handler and replies with render(ctx)
97
- * if (wallet.balance < cost) throw new TooPoor(wallet.balance);
155
+ * Agnostic, so it attaches to any handler kind. Often paired with `RequirePermissions` in an {@link and}.
156
+ *
157
+ * @param options - Reword the default refusal with `message`, or replace it with `notice`.
158
+ *
159
+ * @see the `@Gated` decorator from `seedcord`
160
+ *
161
+ * @example
162
+ * ```ts
163
+ * import { PermissionFlagsBits } from 'discord.js';
164
+ *
165
+ * \@Gated(GuildOnly(), RequirePermissions([PermissionFlagsBits.Administrator]))
166
+ * \@SlashRoute('maintenance')
167
+ * class Maintenance extends SlashHandler<'maintenance'> {
168
+ * async execute() {
169
+ * // ...
170
+ * }
171
+ * }
98
172
  * ```
99
173
  */
100
- declare abstract class Notice extends Error {
174
+ declare function GuildOnly(options?: GateNoticeOptions): Gate<GateContextBase, 'GuildOnly'>;
175
+ /**
176
+ * Passes in a direct message, else refuses.
177
+ *
178
+ * Agnostic, so it attaches to any handler kind. The inverse of {@link GuildOnly}, so combining the two in an {@link and} can never pass.
179
+ *
180
+ * @param options - Reword the default refusal with `message`, or replace it with `notice`.
181
+ *
182
+ * @see the `@Gated` decorator from `seedcord`
183
+ *
184
+ * @example
185
+ * ```ts
186
+ * \@Gated(DmOnly())
187
+ * \@SlashRoute('verify')
188
+ * class VerifyHandler extends SlashHandler<'verify'> {
189
+ * async execute() {
190
+ * // ...
191
+ * }
192
+ * }
193
+ * ```
194
+ */
195
+ declare function DmOnly(options?: GateNoticeOptions): Gate<GateContextBase, 'DmOnly'>;
196
+ //#endregion
197
+ //#region src/gates/catalog/Cooldown.d.ts
198
+ /**
199
+ * Options for {@link Cooldown}. `per` sets the bucket the window applies to and `limit` the uses allowed per
200
+ * window. `message` rewords the refusal and `notice` replaces it, both receiving the epoch ms the key frees
201
+ * up so the refusal can show the retry time.
202
+ *
203
+ * @example
204
+ * ```ts
205
+ * // one use per channel, rewording the refusal with the retry time
206
+ * Cooldown('10s', {
207
+ * per: 'channel',
208
+ * message: (resetAt) => `Slow down. Try again <t:${Math.round(resetAt / 1000)}:R>.`
209
+ * });
210
+ * ```
211
+ *
212
+ * @example
213
+ * ```ts
214
+ * // five uses per minute, per user
215
+ * Cooldown('1m', { limit: 5 });
216
+ * ```
217
+ */
218
+ interface CooldownOptions {
101
219
  /**
102
- * Whether this denial is a reported fault. True also logs it and publishes it to the `handledException`
103
- * bus. The user always sees {@link Notice.render} either way.
220
+ * The bucket the cooldown window applies to. 'user' scopes by user ID, falls back to global when the source carries no user id. 'guild' scopes by guild ID (falls back to global if no guild), and 'channel' scopes by channel ID (falls back to global if no channel).
104
221
  *
105
- * @defaultValue `false`
222
+ * @defaultValue `'user'`
106
223
  */
107
- report: boolean;
224
+ per?: 'user' | 'guild' | 'channel';
108
225
  /**
109
- * Whether the reply is ephemeral, so only the invoking user sees it. Set it false for a refusal the
110
- * whole channel should see.
226
+ * Uses allowed inside one window before the gate refuses.
111
227
  *
112
- * @defaultValue `true`
228
+ * @defaultValue `1`
113
229
  */
114
- ephemeral: boolean;
230
+ limit?: number;
115
231
  /**
116
- * A short one-line reason. When every arm of an `or` gate refuses and each refusal sets this, `or`
117
- * lists them instead of showing a neutral message.
232
+ * Reword the refusal, keeping the standard notice card. Receives the epoch ms the key frees up, so the
233
+ * text can include the retry time with `<t:${Math.round(resetAt / 1000)}:R>`.
118
234
  */
119
- summary?: string;
120
- protected constructor(message: string, options?: ErrorOptions);
235
+ message?: (resetAt: EpochMs) => string;
121
236
  /**
122
- * Builds what the user sees. Called fresh each time the denial is shown, so the builders are new
123
- * and the bot color resolves at render time rather than at construction time.
237
+ * Replace the refusal Notice entirely, for full control or a translated copy. Receives the epoch ms the
238
+ * key frees up.
124
239
  */
125
- abstract render(ctx: RenderContext): ReplyResponse;
240
+ notice?: (resetAt: EpochMs) => Notice;
126
241
  }
242
+ /**
243
+ * Allows `limit` uses per window, scoped by `per`. A number `duration` is **seconds**, a string is
244
+ * a duration like `30m` or `24h`. An unparseable string throws a **SeedcordTypeError** at construction. The
245
+ * slot is charged in commit, only after the whole gate set passes, so a later refusal never burns the cooldown.
246
+ * That split also means two requests racing the same key can both pass before either charges the slot.
247
+ * Each call gets its own bucket, so two handlers never share a window. Reword the refusal with {@link CooldownOptions.message}
248
+ * or replace it with {@link CooldownOptions.notice}.
249
+ *
250
+ * @param duration - A number is seconds, a string is a duration like `30m` or `24h`. An unparseable string throws a **SeedcordTypeError**.
251
+ * @param options - Sets the scope with `per`, the uses per window with `limit`, and the refusal text with `message` or `notice`.
252
+ *
253
+ * @see the `@Gated` decorator from `seedcord`
254
+ * @see {@link IRateLimiter}
255
+ *
256
+ * @example
257
+ * ```ts
258
+ * // a number argument is SECONDS
259
+ * \@Gated(Cooldown(5))
260
+ * \@SlashRoute('daily')
261
+ * class DailyHandler extends SlashHandler<'daily'> {
262
+ * async execute() {
263
+ * // ...
264
+ * }
265
+ * }
266
+ * ```
267
+ *
268
+ * @example
269
+ * ```ts
270
+ * // a string is a duration, here scoped per guild (the default is per user)
271
+ * Cooldown('30m', { per: 'guild' });
272
+ * ```
273
+ *
274
+ * @example
275
+ * ```ts
276
+ * // a burst, 3 uses per 10 minutes per user, then it refuses until a slot frees
277
+ * \@Gated(Cooldown('10m', { limit: 3 }))
278
+ * \@SlashRoute('report')
279
+ * class ReportHandler extends SlashHandler<'report'> {
280
+ * async execute() {
281
+ * // ...
282
+ * }
283
+ * }
284
+ * ```
285
+ */
286
+ declare function Cooldown(duration: number | ValidDuration, options?: CooldownOptions): EffectGate<GateContextBase, 'Cooldown'>;
127
287
  //#endregion
128
288
  //#region src/stops/Fault.d.ts
129
289
  /**
@@ -218,5 +378,5 @@ declare function paginate<Item>(items: readonly Item[], page: number, perPage: n
218
378
  /** Package version */
219
379
  declare const version: string;
220
380
  //#endregion
221
- export { BuilderComponent, type BuilderType, CustomId, Fault, type MessageContextMenuRegistry, Notice, type OptionKind, type PageView, RowComponent, type RowType, Silence, type SlashOption, type SlashOptionRegistry, type UserContextMenuRegistry, paginate, version };
381
+ export { BuilderComponent, type BuilderType, Cooldown, type CooldownOptions, type CoreBase, CustomId, DmOnly, type EffectGate, Fault, type Gate, type GateContextBase, type GateNoticeOptions, GuildOnly, type MessageContextMenuRegistry, Notice, type OptionKind, OwnerOnly, type PageView, RegisterCommand, type RequiredOf, RowComponent, type RowType, Silence, type SlashOption, type SlashOptionRegistry, type UserContextMenuRegistry, and, defineEffectGate, defineGate, or, paginate, version };
222
382
  //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs CHANGED
@@ -1,7 +1,113 @@
1
- import { a as Notice, i as NoticeCard, o as BuilderComponent, s as RowComponent, t as CustomId } from "./CustomId-XgZ2KDgT.mjs";
1
+ import { S as CommandMetadataKey, _ as NoticeCard, c as runCheck, d as NeedsAny, f as NotAllowed, g as OnCooldown, h as NotOwner, i as pickNotice, m as NotInGuild, o as markCommits, p as NotInDm, s as rollbackCommits, t as CustomId, v as BuilderComponent, x as Notice, y as RowComponent } from "./CustomId-Bq6WagiA.mjs";
2
+ import "reflect-metadata";
2
3
  import { SeedcordErrorCode } from "@seedcord/errors";
3
- import { SeedcordRangeError } from "@seedcord/errors/internal";
4
+ import { SeedcordError, SeedcordRangeError, SeedcordTypeError } from "@seedcord/errors/internal";
5
+ import { parseDuration } from "@seedcord/utils";
4
6
 
7
+ //#region src/decorators/Command.ts
8
+ function RegisterCommand(scope, guilds = []) {
9
+ return (ctor) => {
10
+ const existingMeta = Reflect.getOwnMetadata(CommandMetadataKey, ctor);
11
+ if (existingMeta) throw new SeedcordError(SeedcordErrorCode.DecoratorCommandAlreadyRegistered, [
12
+ ctor.name,
13
+ existingMeta.scope,
14
+ scope
15
+ ]);
16
+ if (scope === "global" && guilds.length > 0) throw new SeedcordError(SeedcordErrorCode.DecoratorCommandGlobalWithGuilds);
17
+ if (scope === "guild" && (!Array.isArray(guilds) || guilds.length === 0)) throw new SeedcordError(SeedcordErrorCode.DecoratorCommandGuildWithoutGuilds);
18
+ const meta = scope === "global" ? { scope } : {
19
+ scope,
20
+ guilds
21
+ };
22
+ Reflect.defineMetadata(CommandMetadataKey, meta, ctor);
23
+ };
24
+ }
25
+
26
+ //#endregion
27
+ //#region src/gates/Gate.ts
28
+ /**
29
+ * Builds a {@link Gate} from a check. The check refuses by throwing a Notice or a Silence and passes
30
+ * by returning. The required context is inferred from `ctx`, so annotate it as narrowly as the fields
31
+ * the check reads. No annotation defaults to {@link GateContextBase}, an agnostic gate that fits every
32
+ * handler.
33
+ *
34
+ * @typeParam Name - The gate's name captured as a literal, so a mismatch error can name the gate.
35
+ * @typeParam Ctx - The context the check reads, inferred from the `ctx` annotation.
36
+ *
37
+ * @param name - The gate's name, used in mismatch errors.
38
+ * @param fn - The check, which refuses by throwing a Notice or a Silence and passes by returning.
39
+ *
40
+ * @see {@link defineEffectGate}
41
+ * @see the `@Gated` decorator from `seedcord`
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * // a gate factory that takes its own option and closes over it
46
+ * function MinRoles(min: number): Gate<GateContextBase, 'MinRoles'> {
47
+ * return defineGate('MinRoles', (ctx) => {
48
+ * if (ctx.memberRoleIds.length < min) throw new NotEnoughRoles(min);
49
+ * });
50
+ * }
51
+ * ```
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * // agnostic gate (no ctx annotation): fits any handler
56
+ * const Owner = defineGate('owner', (ctx) => {
57
+ * if (!ctx.userId) throw new NotOwner();
58
+ * });
59
+ * ```
60
+ */
61
+ function defineGate(name, fn) {
62
+ return {
63
+ name,
64
+ check: async (ctx) => fn(ctx)
65
+ };
66
+ }
67
+ /**
68
+ * Builds an {@link EffectGate} from a `check` and a `commit`. `check` peeks and refuses by throwing, and
69
+ * `commit` applies the side effect, running only once the whole gate set passes so a later refusal never
70
+ * commits. In an `or`, a refusing arm's queued commit is rolled back. This is how {@link Cooldown} peeks
71
+ * in `check` and charges the slot in `commit`.
72
+ *
73
+ * @typeParam Name - The gate's name captured as a literal, so a mismatch error can name the gate.
74
+ * @typeParam Ctx - The context both `check` and `commit` read, inferred from the `ctx` annotation.
75
+ *
76
+ * @param name - The gate's name, used in mismatch errors.
77
+ * @param check - Peeks and refuses by throwing, without applying the side effect.
78
+ * @param commit - Applies the side effect, running only after the whole gate set passes.
79
+ *
80
+ * @see {@link defineGate}
81
+ * @see the `@Gated` decorator from `seedcord`
82
+ * @see {@link Cooldown}
83
+ *
84
+ * @example
85
+ * ```ts
86
+ * // an effect-gate factory that takes its own limit and closes over it
87
+ * function UsesPerDay(max: number): EffectGate<GateContextBase, 'UsesPerDay'> {
88
+ * return defineEffectGate(
89
+ * 'UsesPerDay',
90
+ * (ctx) => {
91
+ * // peek and refuse by throwing, do not mutate yet
92
+ * if (usedToday(ctx.userId) >= max) throw new OutOfUsesNotice(max);
93
+ * },
94
+ * (ctx) => {
95
+ * // runs only after the whole gate set passes
96
+ * recordUse(ctx.userId);
97
+ * }
98
+ * );
99
+ * }
100
+ * ```
101
+ */
102
+ function defineEffectGate(name, check, commit) {
103
+ return {
104
+ name,
105
+ check: async (ctx) => check(ctx),
106
+ commit: async (ctx) => commit(ctx)
107
+ };
108
+ }
109
+
110
+ //#endregion
5
111
  //#region src/stops/Fault.ts
6
112
  /**
7
113
  * A generic fault you throw after catching an error you do not have a specific message for.
@@ -40,6 +146,212 @@ var Fault = class extends Notice {
40
146
  }
41
147
  };
42
148
 
149
+ //#endregion
150
+ //#region src/gates/combinators.ts
151
+ function and(...gates) {
152
+ return defineGate(gates.map((gate) => gate.name).join(" & "), async (ctx) => {
153
+ for (const gate of gates) await runCheck(gate, ctx);
154
+ });
155
+ }
156
+ function isOrOptions(arg) {
157
+ return !("check" in arg);
158
+ }
159
+ function or(...args) {
160
+ const last = args.at(-1);
161
+ const options = last !== void 0 && isOrOptions(last) ? last : void 0;
162
+ const gates = options ? args.slice(0, -1) : args;
163
+ return defineGate(gates.map((gate) => gate.name).join(" | "), async (ctx) => {
164
+ const summaries = [];
165
+ let everyArmHasSummary = true;
166
+ for (const gate of gates) {
167
+ const mark = markCommits(ctx);
168
+ try {
169
+ await runCheck(gate, ctx);
170
+ return;
171
+ } catch (error) {
172
+ if (error instanceof Notice && !(error instanceof Fault) && !error.report) {
173
+ rollbackCommits(ctx, mark);
174
+ if (error.summary === void 0) everyArmHasSummary = false;
175
+ else summaries.push(error.summary);
176
+ continue;
177
+ }
178
+ throw error;
179
+ }
180
+ }
181
+ if (options) throw typeof options.fail === "function" ? options.fail(ctx) : options.fail;
182
+ if (everyArmHasSummary && summaries.length > 0) throw new NeedsAny(summaries);
183
+ throw new NotAllowed();
184
+ });
185
+ }
186
+
187
+ //#endregion
188
+ //#region src/gates/catalog/access.ts
189
+ /**
190
+ * Passes only for a user id listed in `config.ownerIds`, else refuses.
191
+ *
192
+ * Agnostic, so it attaches to interaction and event handlers alike. With no `ownerIds` configured it refuses every caller. Pass {@link GateNoticeOptions} to reword or replace the refusal.
193
+ *
194
+ * @param options - Reword the default refusal with `message`, or replace it with `notice`.
195
+ *
196
+ * @see the `@Gated` decorator from `seedcord`
197
+ *
198
+ * @example
199
+ * ```ts
200
+ * \@Gated(OwnerOnly())
201
+ * \@SlashRoute('shutdown')
202
+ * class ShutdownHandler extends SlashHandler<'shutdown'> {
203
+ * async execute() {
204
+ * // ...
205
+ * }
206
+ * }
207
+ * ```
208
+ *
209
+ * @example
210
+ * ```ts
211
+ * // reword the refusal, keeping the embed styling
212
+ * OwnerOnly({ message: 'Owners only.' });
213
+ * ```
214
+ */
215
+ function OwnerOnly(options) {
216
+ return defineGate("OwnerOnly", (ctx) => {
217
+ const owners = ctx.core.config.ownerIds ?? [];
218
+ if (ctx.userId !== null && owners.includes(ctx.userId)) return;
219
+ throw pickNotice(options, (message) => new NotOwner(message));
220
+ });
221
+ }
222
+ /**
223
+ * Passes inside a guild, else refuses.
224
+ *
225
+ * Agnostic, so it attaches to any handler kind. Often paired with `RequirePermissions` in an {@link and}.
226
+ *
227
+ * @param options - Reword the default refusal with `message`, or replace it with `notice`.
228
+ *
229
+ * @see the `@Gated` decorator from `seedcord`
230
+ *
231
+ * @example
232
+ * ```ts
233
+ * import { PermissionFlagsBits } from 'discord.js';
234
+ *
235
+ * \@Gated(GuildOnly(), RequirePermissions([PermissionFlagsBits.Administrator]))
236
+ * \@SlashRoute('maintenance')
237
+ * class Maintenance extends SlashHandler<'maintenance'> {
238
+ * async execute() {
239
+ * // ...
240
+ * }
241
+ * }
242
+ * ```
243
+ */
244
+ function GuildOnly(options) {
245
+ return defineGate("GuildOnly", (ctx) => {
246
+ if (ctx.guildId) return;
247
+ throw pickNotice(options, (message) => new NotInGuild(message));
248
+ });
249
+ }
250
+ /**
251
+ * Passes in a direct message, else refuses.
252
+ *
253
+ * Agnostic, so it attaches to any handler kind. The inverse of {@link GuildOnly}, so combining the two in an {@link and} can never pass.
254
+ *
255
+ * @param options - Reword the default refusal with `message`, or replace it with `notice`.
256
+ *
257
+ * @see the `@Gated` decorator from `seedcord`
258
+ *
259
+ * @example
260
+ * ```ts
261
+ * \@Gated(DmOnly())
262
+ * \@SlashRoute('verify')
263
+ * class VerifyHandler extends SlashHandler<'verify'> {
264
+ * async execute() {
265
+ * // ...
266
+ * }
267
+ * }
268
+ * ```
269
+ */
270
+ function DmOnly(options) {
271
+ return defineGate("DmOnly", (ctx) => {
272
+ if (!ctx.guildId) return;
273
+ throw pickNotice(options, (message) => new NotInDm(message));
274
+ });
275
+ }
276
+
277
+ //#endregion
278
+ //#region src/gates/catalog/Cooldown.ts
279
+ let bucketSeq = 0;
280
+ function scopeValue(ctx, per) {
281
+ if (per === "guild") return ctx.guildId ?? "global";
282
+ if (per === "channel") return ctx.channelId ?? "global";
283
+ return ctx.userId ?? "global";
284
+ }
285
+ /**
286
+ * Allows `limit` uses per window, scoped by `per`. A number `duration` is **seconds**, a string is
287
+ * a duration like `30m` or `24h`. An unparseable string throws a **SeedcordTypeError** at construction. The
288
+ * slot is charged in commit, only after the whole gate set passes, so a later refusal never burns the cooldown.
289
+ * That split also means two requests racing the same key can both pass before either charges the slot.
290
+ * Each call gets its own bucket, so two handlers never share a window. Reword the refusal with {@link CooldownOptions.message}
291
+ * or replace it with {@link CooldownOptions.notice}.
292
+ *
293
+ * @param duration - A number is seconds, a string is a duration like `30m` or `24h`. An unparseable string throws a **SeedcordTypeError**.
294
+ * @param options - Sets the scope with `per`, the uses per window with `limit`, and the refusal text with `message` or `notice`.
295
+ *
296
+ * @see the `@Gated` decorator from `seedcord`
297
+ * @see {@link IRateLimiter}
298
+ *
299
+ * @example
300
+ * ```ts
301
+ * // a number argument is SECONDS
302
+ * \@Gated(Cooldown(5))
303
+ * \@SlashRoute('daily')
304
+ * class DailyHandler extends SlashHandler<'daily'> {
305
+ * async execute() {
306
+ * // ...
307
+ * }
308
+ * }
309
+ * ```
310
+ *
311
+ * @example
312
+ * ```ts
313
+ * // a string is a duration, here scoped per guild (the default is per user)
314
+ * Cooldown('30m', { per: 'guild' });
315
+ * ```
316
+ *
317
+ * @example
318
+ * ```ts
319
+ * // a burst, 3 uses per 10 minutes per user, then it refuses until a slot frees
320
+ * \@Gated(Cooldown('10m', { limit: 3 }))
321
+ * \@SlashRoute('report')
322
+ * class ReportHandler extends SlashHandler<'report'> {
323
+ * async execute() {
324
+ * // ...
325
+ * }
326
+ * }
327
+ * ```
328
+ */
329
+ function Cooldown(duration, options) {
330
+ let windowMs;
331
+ if (typeof duration === "number") windowMs = duration * 1e3;
332
+ else {
333
+ const parsed = parseDuration(duration);
334
+ if (parsed === null) throw new SeedcordTypeError(SeedcordErrorCode.GateInvalidCooldownDuration, [duration]);
335
+ windowMs = parsed;
336
+ }
337
+ if (windowMs <= 0) throw new SeedcordTypeError(SeedcordErrorCode.GateInvalidCooldownDuration, [String(duration)]);
338
+ const bucket = `cooldown:${bucketSeq++}`;
339
+ const per = options?.per ?? "user";
340
+ const window = options?.limit === void 0 ? { windowMs } : {
341
+ windowMs,
342
+ limit: options.limit
343
+ };
344
+ const keyOf = (ctx) => `${bucket}:${scopeValue(ctx, per)}`;
345
+ return defineEffectGate("Cooldown", async (ctx) => {
346
+ const result = await ctx.core.rateLimiter.peek(keyOf(ctx), window);
347
+ if (!result.limited) return;
348
+ if (options?.notice) throw options.notice(result.resetAt);
349
+ throw new OnCooldown(result.resetAt, options?.message?.(result.resetAt));
350
+ }, async (ctx) => {
351
+ await ctx.core.rateLimiter.charge(keyOf(ctx), window);
352
+ });
353
+ }
354
+
43
355
  //#endregion
44
356
  //#region src/stops/Silence.ts
45
357
  /**
@@ -98,8 +410,8 @@ function paginate(items, page, perPage) {
98
410
  //#endregion
99
411
  //#region src/index.ts
100
412
  /** Package version */
101
- const version = "0.1.0-next.1";
413
+ const version = "0.1.0-next.2";
102
414
 
103
415
  //#endregion
104
- export { BuilderComponent, CustomId, Fault, Notice, RowComponent, Silence, paginate, version };
416
+ export { BuilderComponent, Cooldown, CustomId, DmOnly, Fault, GuildOnly, Notice, OwnerOnly, RegisterCommand, RowComponent, Silence, and, defineEffectGate, defineGate, or, paginate, version };
105
417
  //# sourceMappingURL=index.mjs.map