Skip to content

Commit 4a3c76a

Browse files
authored
feat: stricter types for preconditions (#226)
Added `Command#parseConstructorPreConditions`. Added `Command#parseConstructorPreConditionsRunIn`. Added `Command#parseConstructorPreConditionsNsfw`. Added `Command#parseConstructorPreConditionsRequiredClientPermissions`. Added `Command#parseConstructorPreConditionsCooldown`. Added `CommandPreConditions.Permissions`. Added `CommandOptions.cooldownScope`. Added `CommandOptions.requiredClientPermissions`. Added `Preconditions` interface, this strict types all precondition names and contexts. Added `PreconditionContainerArray#append`. Added `SimplePreconditionSingleResolvableDetails`. Fixed cooldown precondition not working when defining alias properties from `Command`. BREAKING CHANGE: Changed `Command#preconditions` to `PreconditionContainerArray`. BREAKING CHANGE: Removed `Command#resolveConstructorPreConditions`. BREAKING CHANGE: Renamed `CommandOptions.cooldownBucket` to `cooldownLimit`. BREAKING CHANGE: Renamed `CommandOptions.cooldownDuration` to `cooldownDelay`. BREAKING CHANGE: Renamed `BucketType` to `BucketScope`. BREAKING CHANGE: Changed `PreconditionSingleResolvableDetails` to take a type parameter. BREAKING CHANGE: Changed `PreconditionSingleResolvable` to use `Preconditions`'s type. BREAKING CHANGE: Renamed `CooldownContext.bucketType` to `scope`.
1 parent 695cca2 commit 4a3c76a

File tree

8 files changed

+217
-59
lines changed

8 files changed

+217
-59
lines changed

src/lib/structures/Command.ts

+95-32
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { AliasPiece, AliasPieceOptions, PieceContext } from '@sapphire/pieces';
22
import { Awaited, isNullish } from '@sapphire/utilities';
3-
import type { Message } from 'discord.js';
3+
import { Message, PermissionResolvable, Permissions } from 'discord.js';
44
import * as Lexure from 'lexure';
55
import { Args } from '../parsers/Args';
6-
import type { IPreconditionContainer } from '../utils/preconditions/IPreconditionContainer';
6+
import { BucketScope } from '../types/Enums';
77
import { PreconditionContainerArray, PreconditionEntryResolvable } from '../utils/preconditions/PreconditionContainerArray';
88
import { FlagStrategyOptions, FlagUnorderedStrategy } from '../utils/strategies/FlagUnorderedStrategy';
99

@@ -18,7 +18,7 @@ export abstract class Command<T = Args> extends AliasPiece {
1818
* The preconditions to be run.
1919
* @since 1.0.0
2020
*/
21-
public preconditions: IPreconditionContainer;
21+
public preconditions: PreconditionContainerArray;
2222

2323
/**
2424
* Longer version of command's summary and how to use it
@@ -44,8 +44,8 @@ export abstract class Command<T = Args> extends AliasPiece {
4444
* @param context The context.
4545
* @param options Optional Command settings.
4646
*/
47-
protected constructor(context: PieceContext, { name, ...options }: CommandOptions = {}) {
48-
super(context, { ...options, name: (name ?? context.name).toLowerCase() });
47+
protected constructor(context: PieceContext, options: CommandOptions = {}) {
48+
super(context, { ...options, name: (options.name ?? context.name).toLowerCase() });
4949
this.description = options.description ?? '';
5050
this.detailedDescription = options.detailedDescription ?? '';
5151
this.strategy = new FlagUnorderedStrategy(options);
@@ -65,7 +65,8 @@ export abstract class Command<T = Args> extends AliasPiece {
6565
this.aliases = [...this.aliases, ...dashLessAliases];
6666
}
6767

68-
this.preconditions = new PreconditionContainerArray(this.resolveConstructorPreConditions(options));
68+
this.preconditions = new PreconditionContainerArray();
69+
this.parseConstructorPreConditions(options);
6970
}
7071

7172
/**
@@ -99,33 +100,80 @@ export abstract class Command<T = Args> extends AliasPiece {
99100
};
100101
}
101102

102-
protected resolveConstructorPreConditions(options: CommandOptions): readonly PreconditionEntryResolvable[] {
103-
const preconditions = options.preconditions?.slice() ?? [];
104-
if (options.nsfw) preconditions.push(CommandPreConditions.NotSafeForWork);
103+
/**
104+
* Parses the command's options and processes them, calling {@link Command#parseConstructorPreConditionsRunIn},
105+
* {@link Command#parseConstructorPreConditionsNsfw},
106+
* {@link Command#parseConstructorPreConditionsRequiredClientPermissions}, and
107+
* {@link Command#parseConstructorPreConditionsCooldown}.
108+
* @since 2.0.0
109+
* @param options The command options given from the constructor.
110+
*/
111+
protected parseConstructorPreConditions(options: CommandOptions): void {
112+
this.parseConstructorPreConditionsRunIn(options);
113+
this.parseConstructorPreConditionsNsfw(options);
114+
this.parseConstructorPreConditionsRequiredClientPermissions(options);
115+
this.parseConstructorPreConditionsCooldown(options);
116+
}
117+
118+
/**
119+
* Appends the `NSFW` precondition if {@link CommandOptions.nsfw} is set to true.
120+
* @param options The command options given from the constructor.
121+
*/
122+
protected parseConstructorPreConditionsNsfw(options: CommandOptions) {
123+
if (options.nsfw) this.preconditions.append(CommandPreConditions.NotSafeForWork);
124+
}
105125

126+
/**
127+
* Appends the `DMOnly`, `GuildOnly`, `NewsOnly`, and `TextOnly` preconditions based on the values passed in
128+
* {@link CommandOptions.runIn}, optimizing in specific cases (`NewsOnly` + `TextOnly` = `GuildOnly`; `DMOnly` +
129+
* `GuildOnly` = `null`), defaulting to `null`, which doesn't add a precondition.
130+
* @param options The command options given from the constructor.
131+
*/
132+
protected parseConstructorPreConditionsRunIn(options: CommandOptions) {
106133
const runIn = this.resolveConstructorPreConditionsRunType(options.runIn);
107-
if (runIn !== null) preconditions.push(runIn);
134+
if (runIn !== null) this.preconditions.append(runIn);
135+
}
108136

109-
const cooldownBucket = options.cooldownBucket ?? 1;
110-
if (cooldownBucket && options.cooldownDuration) {
111-
preconditions.push({ name: CommandPreConditions.Cooldown, context: { bucket: cooldownBucket, cooldown: options.cooldownDuration } });
137+
/**
138+
* Appends the `Permissions` precondition when {@link CommandOptions.requiredClientPermissions} resolves to a
139+
* non-zero bitfield.
140+
* @param options The command options given from the constructor.
141+
*/
142+
protected parseConstructorPreConditionsRequiredClientPermissions(options: CommandOptions) {
143+
const permissions = new Permissions(options.requiredClientPermissions);
144+
if (permissions.bitfield !== 0) {
145+
this.preconditions.append({ name: CommandPreConditions.Permissions, context: { permissions } });
112146
}
147+
}
113148

114-
return preconditions;
149+
/**
150+
* Appends the `Cooldown` precondition when {@link CommandOptions.cooldownLimit} and
151+
* {@link CommandOptions.cooldownDelay} are both non-zero.
152+
* @param options The command options given from the constructor.
153+
*/
154+
protected parseConstructorPreConditionsCooldown(options: CommandOptions) {
155+
const limit = options.cooldownLimit ?? 1;
156+
const delay = options.cooldownDelay ?? 0;
157+
if (limit && delay) {
158+
this.preconditions.append({
159+
name: CommandPreConditions.Cooldown,
160+
context: { scope: options.cooldownScope ?? BucketScope.User, limit, delay }
161+
});
162+
}
115163
}
116164

117-
private resolveConstructorPreConditionsRunType(runIn: CommandOptions['runIn']): CommandPreConditions[] | null {
165+
private resolveConstructorPreConditionsRunType(runIn: CommandOptions['runIn']) {
118166
if (isNullish(runIn)) return null;
119167
if (typeof runIn === 'string') {
120168
switch (runIn) {
121169
case 'dm':
122-
return [CommandPreConditions.DirectMessageOnly];
170+
return CommandPreConditions.DirectMessageOnly;
123171
case 'text':
124-
return [CommandPreConditions.TextOnly];
172+
return CommandPreConditions.TextOnly;
125173
case 'news':
126-
return [CommandPreConditions.NewsOnly];
174+
return CommandPreConditions.NewsOnly;
127175
case 'guild':
128-
return [CommandPreConditions.GuildOnly];
176+
return CommandPreConditions.GuildOnly;
129177
default:
130178
return null;
131179
}
@@ -144,13 +192,13 @@ export abstract class Command<T = Args> extends AliasPiece {
144192
// If runs everywhere, optimise to null:
145193
if (dm && guild) return null;
146194

147-
const array: CommandPreConditions[] = [];
148-
if (dm) array.push(CommandPreConditions.DirectMessageOnly);
149-
if (guild) array.push(CommandPreConditions.GuildOnly);
150-
else if (text) array.push(CommandPreConditions.TextOnly);
151-
else if (news) array.push(CommandPreConditions.NewsOnly);
195+
const preconditions = new PreconditionContainerArray();
196+
if (dm) preconditions.append(CommandPreConditions.DirectMessageOnly);
197+
if (guild) preconditions.append(CommandPreConditions.GuildOnly);
198+
else if (text) preconditions.append(CommandPreConditions.TextOnly);
199+
else if (news) preconditions.append(CommandPreConditions.NewsOnly);
152200

153-
return array;
201+
return preconditions;
154202
}
155203
}
156204

@@ -166,11 +214,12 @@ export type CommandOptionsRunType = 'dm' | 'text' | 'news' | 'guild';
166214
*/
167215
export const enum CommandPreConditions {
168216
Cooldown = 'Cooldown',
169-
NotSafeForWork = 'NSFW',
170217
DirectMessageOnly = 'DMOnly',
171-
TextOnly = 'TextOnly',
218+
GuildOnly = 'GuildOnly',
172219
NewsOnly = 'NewsOnly',
173-
GuildOnly = 'GuildOnly'
220+
NotSafeForWork = 'NSFW',
221+
Permissions = 'Permissions',
222+
TextOnly = 'TextOnly'
174223
}
175224

176225
/**
@@ -227,18 +276,32 @@ export interface CommandOptions extends AliasPieceOptions, FlagStrategyOptions {
227276
nsfw?: boolean;
228277

229278
/**
230-
* Sets the bucket of the cool-down, if set to a non-zero value alongside {@link CommandOptions.cooldownDuration}, the `Cooldown` precondition will be added to the list.
279+
* The amount of entries the cooldown can have before filling up, if set to a non-zero value alongside {@link CommandOptions.cooldownDelay}, the `Cooldown` precondition will be added to the list.
231280
* @since 2.0.0
232281
* @default 1
233282
*/
234-
cooldownBucket?: number;
283+
cooldownLimit?: number;
284+
285+
/**
286+
* The time in milliseconds for the cooldown entries to reset, if set to a non-zero value alongside {@link CommandOptions.cooldownLimit}, the `Cooldown` precondition will be added to the list.
287+
* @since 2.0.0
288+
* @default 0
289+
*/
290+
cooldownDelay?: number;
291+
292+
/**
293+
* The scope of the cooldown entries.
294+
* @since 2.0.0
295+
* @default BucketScope.User
296+
*/
297+
cooldownScope?: BucketScope;
235298

236299
/**
237-
* Sets the duration of the tickets in the cool-down, if set to a non-zero value alongside {@link CommandOptions.cooldownBucket}, the `Cooldown` precondition will be added to the list.
300+
* The required permissions for the client.
238301
* @since 2.0.0
239302
* @default 0
240303
*/
241-
cooldownDuration?: number;
304+
requiredClientPermissions?: PermissionResolvable;
242305

243306
/**
244307
* The channels the command should run in. If set to `null`, no precondition entry will be added. Some optimizations are applied when given an array to reduce the amount of preconditions run (e.g. `'text'` and `'news'` becomes `'guild'`, and if both `'dm'` and `'guild'` are defined, then no precondition entry is added as it runs in all channels).

src/lib/structures/Precondition.ts

+71-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Piece, PieceContext, PieceOptions } from '@sapphire/pieces';
22
import type { Awaited } from '@sapphire/utilities';
3-
import type { Message } from 'discord.js';
3+
import type { Message, Permissions } from 'discord.js';
44
import { PreconditionError } from '../errors/PreconditionError';
55
import type { UserError } from '../errors/UserError';
66
import { err, ok, Result } from '../parsers/Result';
7+
import type { BucketScope } from '../types/Enums';
78
import type { Command } from './Command';
89

910
export type PreconditionResult = Awaited<Result<unknown, UserError>>;
@@ -32,6 +33,75 @@ export abstract class Precondition extends Piece {
3233
}
3334
}
3435

36+
/**
37+
* The registered preconditions and their contexts, if any. When registering new ones, it is recommended to use
38+
* [module augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) so
39+
* custom ones are registered.
40+
*
41+
* When a key's value is `never`, it means that it does not take any context, which allows you to pass its identifier as
42+
* a bare string (e.g. `preconditions: ['NSFW']`), however, if context is required, a non-`never` type should be passed,
43+
* which will type {@link PreconditionContainerArray#append} and require an object with the name and a `context` with
44+
* the defined type.
45+
*
46+
* @example
47+
* ```typescript
48+
* declare module '@sapphire/framework' {
49+
* interface Preconditions {
50+
* // A precondition named `Moderator` which does not read `context`:
51+
* Moderator: never;
52+
*
53+
* // A precondition named `ChannelPermissions` which does read `context`:
54+
* ChannelPermissions: {
55+
* permissions: Permissions;
56+
* };
57+
* }
58+
* }
59+
*
60+
* // [✔] These are valid:
61+
* preconditions.append('Moderator');
62+
* preconditions.append({ name: 'Moderator' });
63+
* preconditions.append({
64+
* name: 'ChannelPermissions',
65+
* context: { permissions: new Permissions(8) }
66+
* });
67+
*
68+
* // [X] These are invalid:
69+
* preconditions.append({ name: 'Moderator', context: {} });
70+
* // ➡ `never` keys do not accept `context`.
71+
*
72+
* preconditions.append('ChannelPermissions');
73+
* // ➡ non-`never` keys always require `context`, a string cannot be used.
74+
*
75+
* preconditions.append({
76+
* name: 'ChannelPermissions',
77+
* context: { unknownProperty: 1 }
78+
* });
79+
* // ➡ mismatching `context` properties, `{ unknownProperty: number }` is not
80+
* // assignable to `{ permissions: Permissions }`.
81+
* ```
82+
*/
83+
export interface Preconditions {
84+
Cooldown: {
85+
scope: BucketScope;
86+
delay: number;
87+
limit: number;
88+
};
89+
DMOnly: never;
90+
Enabled: never;
91+
GuildOnly: never;
92+
NewsOnly: never;
93+
NSFW: never;
94+
Permissions: {
95+
permissions: Permissions;
96+
};
97+
TextOnly: never;
98+
}
99+
100+
export type PreconditionKeys = keyof Preconditions;
101+
export type SimplePreconditionKeys = {
102+
[K in PreconditionKeys]: Preconditions[K] extends never ? K : never;
103+
}[PreconditionKeys];
104+
35105
export interface PreconditionOptions extends PieceOptions {
36106
/**
37107
* The position for the precondition to be set at in the global precondition list. If set to `null`, this

src/lib/types/Enums.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,23 @@ export const enum PluginHook {
1313
}
1414

1515
/**
16-
* The level the cooldown applies to
16+
* The scope the cooldown applies to.
1717
*/
18-
export const enum BucketType {
18+
export const enum BucketScope {
1919
/**
20-
* Per channel cooldowns
20+
* Per channel cooldowns.
2121
*/
2222
Channel,
2323
/**
24-
* Global cooldowns
24+
* Global cooldowns.
2525
*/
2626
Global,
2727
/**
28-
* Per guild cooldowns
28+
* Per guild cooldowns.
2929
*/
3030
Guild,
3131
/**
32-
* Per user cooldowns
32+
* Per user cooldowns.
3333
*/
3434
User
3535
}

src/lib/utils/preconditions/PreconditionContainerArray.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { Collection, Message } from 'discord.js';
22
import type { Command } from '../../structures/Command';
3-
import type { PreconditionContext } from '../../structures/Precondition';
3+
import type { PreconditionContext, PreconditionKeys, SimplePreconditionKeys } from '../../structures/Precondition';
44
import type { IPreconditionCondition } from './conditions/IPreconditionCondition';
55
import { PreconditionConditionAnd } from './conditions/PreconditionConditionAnd';
66
import { PreconditionConditionOr } from './conditions/PreconditionConditionOr';
77
import type { IPreconditionContainer, PreconditionContainerReturn } from './IPreconditionContainer';
8-
import { PreconditionContainerSingle, PreconditionSingleResolvable } from './PreconditionContainerSingle';
8+
import {
9+
PreconditionContainerSingle,
10+
PreconditionSingleResolvable,
11+
PreconditionSingleResolvableDetails,
12+
SimplePreconditionSingleResolvableDetails
13+
} from './PreconditionContainerSingle';
914

1015
/**
1116
* The run mode for a {@link PreconditionContainerArray}.
@@ -146,6 +151,13 @@ export class PreconditionContainerArray implements IPreconditionContainer {
146151
return this;
147152
}
148153

154+
public append(keyOrEntries: SimplePreconditionSingleResolvableDetails | SimplePreconditionKeys | PreconditionContainerArray): this;
155+
public append<K extends PreconditionKeys>(entry: PreconditionSingleResolvableDetails<K>): this;
156+
public append(entry: PreconditionContainerArray | PreconditionSingleResolvable): this {
157+
this.entries.push(entry instanceof PreconditionContainerArray ? entry : new PreconditionContainerSingle(entry));
158+
return this;
159+
}
160+
149161
/**
150162
* Runs the container.
151163
* @since 1.0.0

0 commit comments

Comments
 (0)