Skip to content

Commit

Permalink
refactor: strict type preconditions
Browse files Browse the repository at this point in the history
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`.
  • Loading branch information
kyranet committed Jul 13, 2021
1 parent 695cca2 commit d0e9f99
Show file tree
Hide file tree
Showing 7 changed files with 214 additions and 56 deletions.
127 changes: 95 additions & 32 deletions src/lib/structures/Command.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { AliasPiece, AliasPieceOptions, PieceContext } from '@sapphire/pieces';
import { Awaited, isNullish } from '@sapphire/utilities';
import type { Message } from 'discord.js';
import { Message, PermissionResolvable, Permissions } from 'discord.js';
import * as Lexure from 'lexure';
import { Args } from '../parsers/Args';
import type { IPreconditionContainer } from '../utils/preconditions/IPreconditionContainer';
import { BucketScope } from '../types/Enums';
import { PreconditionContainerArray, PreconditionEntryResolvable } from '../utils/preconditions/PreconditionContainerArray';
import { FlagStrategyOptions, FlagUnorderedStrategy } from '../utils/strategies/FlagUnorderedStrategy';

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

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

this.preconditions = new PreconditionContainerArray(this.resolveConstructorPreConditions(options));
this.preconditions = new PreconditionContainerArray();
this.parseConstructorPreConditions(options);
}

/**
Expand Down Expand Up @@ -99,33 +100,80 @@ export abstract class Command<T = Args> extends AliasPiece {
};
}

protected resolveConstructorPreConditions(options: CommandOptions): readonly PreconditionEntryResolvable[] {
const preconditions = options.preconditions?.slice() ?? [];
if (options.nsfw) preconditions.push(CommandPreConditions.NotSafeForWork);
/**
* Parses the command's options and processes them, calling {@link Command#parseConstructorPreConditionsRunIn},
* {@link Command#parseConstructorPreConditionsNsfw},
* {@link Command#parseConstructorPreConditionsRequiredClientPermissions}, and
* {@link Command#parseConstructorPreConditionsCooldown}.
* @since 2.0.0
* @param options The command options given from the constructor.
*/
protected parseConstructorPreConditions(options: CommandOptions): void {
this.parseConstructorPreConditionsRunIn(options);
this.parseConstructorPreConditionsNsfw(options);
this.parseConstructorPreConditionsRequiredClientPermissions(options);
this.parseConstructorPreConditionsCooldown(options);
}

/**
* Appends the `NSFW` precondition if {@link CommandOptions.nsfw} is set to true.
* @param options The command options given from the constructor.
*/
protected parseConstructorPreConditionsNsfw(options: CommandOptions) {
if (options.nsfw) this.preconditions.append(CommandPreConditions.NotSafeForWork);
}

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

const cooldownBucket = options.cooldownBucket ?? 1;
if (cooldownBucket && options.cooldownDuration) {
preconditions.push({ name: CommandPreConditions.Cooldown, context: { bucket: cooldownBucket, cooldown: options.cooldownDuration } });
/**
* Appends the `Permissions` precondition when {@link CommandOptions.requiredClientPermissions} resolves to a
* non-zero bitfield.
* @param options The command options given from the constructor.
*/
protected parseConstructorPreConditionsRequiredClientPermissions(options: CommandOptions) {
const permissions = new Permissions(options.requiredClientPermissions);
if (permissions.bitfield !== 0) {
this.preconditions.append({ name: CommandPreConditions.Permissions, context: { permissions } });
}
}

return preconditions;
/**
* Appends the `Cooldown` precondition when {@link CommandOptions.cooldownLimit} and
* {@link CommandOptions.cooldownDelay} are both non-zero.
* @param options The command options given from the constructor.
*/
protected parseConstructorPreConditionsCooldown(options: CommandOptions) {
const limit = options.cooldownLimit ?? 1;
const delay = options.cooldownDelay ?? 0;
if (limit && delay) {
this.preconditions.append({
name: CommandPreConditions.Cooldown,
context: { scope: options.cooldownScope ?? BucketScope.User, limit, delay }
});
}
}

private resolveConstructorPreConditionsRunType(runIn: CommandOptions['runIn']): CommandPreConditions[] | null {
private resolveConstructorPreConditionsRunType(runIn: CommandOptions['runIn']) {
if (isNullish(runIn)) return null;
if (typeof runIn === 'string') {
switch (runIn) {
case 'dm':
return [CommandPreConditions.DirectMessageOnly];
return CommandPreConditions.DirectMessageOnly;
case 'text':
return [CommandPreConditions.TextOnly];
return CommandPreConditions.TextOnly;
case 'news':
return [CommandPreConditions.NewsOnly];
return CommandPreConditions.NewsOnly;
case 'guild':
return [CommandPreConditions.GuildOnly];
return CommandPreConditions.GuildOnly;
default:
return null;
}
Expand All @@ -144,13 +192,13 @@ export abstract class Command<T = Args> extends AliasPiece {
// If runs everywhere, optimise to null:
if (dm && guild) return null;

const array: CommandPreConditions[] = [];
if (dm) array.push(CommandPreConditions.DirectMessageOnly);
if (guild) array.push(CommandPreConditions.GuildOnly);
else if (text) array.push(CommandPreConditions.TextOnly);
else if (news) array.push(CommandPreConditions.NewsOnly);
const preconditions = new PreconditionContainerArray();
if (dm) preconditions.append(CommandPreConditions.DirectMessageOnly);
if (guild) preconditions.append(CommandPreConditions.GuildOnly);
else if (text) preconditions.append(CommandPreConditions.TextOnly);
else if (news) preconditions.append(CommandPreConditions.NewsOnly);

return array;
return preconditions;
}
}

Expand All @@ -166,11 +214,12 @@ export type CommandOptionsRunType = 'dm' | 'text' | 'news' | 'guild';
*/
export const enum CommandPreConditions {
Cooldown = 'Cooldown',
NotSafeForWork = 'NSFW',
DirectMessageOnly = 'DMOnly',
TextOnly = 'TextOnly',
GuildOnly = 'GuildOnly',
NewsOnly = 'NewsOnly',
GuildOnly = 'GuildOnly'
NotSafeForWork = 'NSFW',
Permissions = 'Permissions',
TextOnly = 'TextOnly'
}

/**
Expand Down Expand Up @@ -227,18 +276,32 @@ export interface CommandOptions extends AliasPieceOptions, FlagStrategyOptions {
nsfw?: boolean;

/**
* 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.
* 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.
* @since 2.0.0
* @default 1
*/
cooldownBucket?: number;
cooldownLimit?: number;

/**
* 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.
* @since 2.0.0
* @default 0
*/
cooldownDelay?: number;

/**
* The scope of the cooldown entries.
* @since 2.0.0
* @default BucketScope.User
*/
cooldownScope?: BucketScope;

/**
* 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.
* The required permissions for the client.
* @since 2.0.0
* @default 0
*/
cooldownDuration?: number;
requiredClientPermissions?: PermissionResolvable;

/**
* 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).
Expand Down
72 changes: 71 additions & 1 deletion src/lib/structures/Precondition.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Piece, PieceContext, PieceOptions } from '@sapphire/pieces';
import type { Awaited } from '@sapphire/utilities';
import type { Message } from 'discord.js';
import type { Message, Permissions } from 'discord.js';
import { PreconditionError } from '../errors/PreconditionError';
import type { UserError } from '../errors/UserError';
import { err, ok, Result } from '../parsers/Result';
import type { BucketScope } from '../types/Enums';
import type { Command } from './Command';

export type PreconditionResult = Awaited<Result<unknown, UserError>>;
Expand Down Expand Up @@ -32,6 +33,75 @@ export abstract class Precondition extends Piece {
}
}

/**
* The registered preconditions and their contexts, if any. When registering new ones, it is recommended to use
* [module augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation) so
* custom ones are registered.
*
* When a key's value is `never`, it means it does not take any context, which allows you to pass its identifier as a
* bare string (e.g. `preconditions: ['NSFW']`), however, if context is required, a non-`never` type should be passed,
* which will type {@link PreconditionContainerArray#append} and require an object with the name and a `context` with
* the defined type.
*
* @example
* ```typescript
* declare module '@sapphire/framework' {
* interface Preconditions {
* // A precondition named `Moderator` which does not read `context`:
* Moderator: never;
*
* // A precondition named `ChannelPermissions` which does read `context`:
* ChannelPermissions: {
* permissions: Permissions;
* };
* }
* }
*
* // [✔] Those are valid:
* preconditions.append('Moderator');
* preconditions.append({ name: 'Moderator' });
* preconditions.append({
* name: 'ChannelPermissions',
* context: { permissions: new Permissions(8) }
* });
*
* // [X] Those are invalid:
* preconditions.append({ name: 'Moderator', context: {} });
* // ➡ `never` keys do not accept `context`.
*
* preconditions.append('ChannelPermissions');
* // ➡ non-`never` keys always require `context`, a string cannot be used.
*
* preconditions.append({
* name: 'ChannelPermissions',
* context: { unknownProperty: 1 }
* });
* // ➡ mismatching `context` properties, `{ unknownProperty: number }` is not
* // assignable to `{ permissions: Permissions }`.
* ```
*/
export interface Preconditions {
Cooldown: {
scope: BucketScope;
delay: number;
limit: number;
};
DMOnly: never;
Enabled: never;
GuildOnly: never;
NewsOnly: never;
NSFW: never;
Permissions: {
permissions: Permissions;
};
TextOnly: never;
}

export type PreconditionKeys = keyof Preconditions;
export type SimplePreconditionKeys = {
[K in PreconditionKeys]: Preconditions[K] extends never ? K : never;
}[PreconditionKeys];

export interface PreconditionOptions extends PieceOptions {
/**
* The position for the precondition to be set at in the global precondition list. If set to `null`, this
Expand Down
12 changes: 6 additions & 6 deletions src/lib/types/Enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,23 @@ export const enum PluginHook {
}

/**
* The level the cooldown applies to
* The scope the cooldown applies to.
*/
export const enum BucketType {
export const enum BucketScope {
/**
* Per channel cooldowns
* Per channel cooldowns.
*/
Channel,
/**
* Global cooldowns
* Global cooldowns.
*/
Global,
/**
* Per guild cooldowns
* Per guild cooldowns.
*/
Guild,
/**
* Per user cooldowns
* Per user cooldowns.
*/
User
}
16 changes: 14 additions & 2 deletions src/lib/utils/preconditions/PreconditionContainerArray.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { Collection, Message } from 'discord.js';
import type { Command } from '../../structures/Command';
import type { PreconditionContext } from '../../structures/Precondition';
import type { PreconditionContext, PreconditionKeys, SimplePreconditionKeys } from '../../structures/Precondition';
import type { IPreconditionCondition } from './conditions/IPreconditionCondition';
import { PreconditionConditionAnd } from './conditions/PreconditionConditionAnd';
import { PreconditionConditionOr } from './conditions/PreconditionConditionOr';
import type { IPreconditionContainer, PreconditionContainerReturn } from './IPreconditionContainer';
import { PreconditionContainerSingle, PreconditionSingleResolvable } from './PreconditionContainerSingle';
import {
PreconditionContainerSingle,
PreconditionSingleResolvable,
PreconditionSingleResolvableDetails,
SimplePreconditionSingleResolvableDetails
} from './PreconditionContainerSingle';

/**
* The run mode for a {@link PreconditionContainerArray}.
Expand Down Expand Up @@ -146,6 +151,13 @@ export class PreconditionContainerArray implements IPreconditionContainer {
return this;
}

public append(keyOrEntries: SimplePreconditionSingleResolvableDetails | SimplePreconditionKeys | PreconditionContainerArray): this;
public append<K extends PreconditionKeys>(entry: PreconditionSingleResolvableDetails<K>): this;
public append(entry: PreconditionContainerArray | PreconditionSingleResolvable): this {
this.entries.push(entry instanceof PreconditionContainerArray ? entry : new PreconditionContainerSingle(entry));
return this;
}

/**
* Runs the container.
* @since 1.0.0
Expand Down
Loading

0 comments on commit d0e9f99

Please sign in to comment.