;
13 | }
14 |
--------------------------------------------------------------------------------
/src/lib/plugins/symbols.ts:
--------------------------------------------------------------------------------
1 | export const preGenericsInitialization: unique symbol = Symbol('SapphireFrameworkPluginsPreGenericsInitialization');
2 | export const preInitialization: unique symbol = Symbol('SapphireFrameworkPluginsPreInitialization');
3 | export const postInitialization: unique symbol = Symbol('SapphireFrameworkPluginsPostInitialization');
4 |
5 | export const preLogin: unique symbol = Symbol('SapphireFrameworkPluginsPreLogin');
6 | export const postLogin: unique symbol = Symbol('SapphireFrameworkPluginsPostLogin');
7 |
--------------------------------------------------------------------------------
/src/lib/precondition-resolvers/clientPermissions.ts:
--------------------------------------------------------------------------------
1 | import { PermissionsBitField, type PermissionResolvable } from 'discord.js';
2 | import { CommandPreConditions } from '../types/Enums';
3 | import type { PreconditionContainerArray } from '../utils/preconditions/PreconditionContainerArray';
4 |
5 | /**
6 | * Appends the `ClientPermissions` precondition when {@link Command.Options.requiredClientPermissions} resolves to a
7 | * non-zero bitfield.
8 | * @param requiredClientPermissions The required client permissions.
9 | * @param preconditionContainerArray The precondition container array to append the precondition to.
10 | */
11 | export function parseConstructorPreConditionsRequiredClientPermissions(
12 | requiredClientPermissions: PermissionResolvable | undefined,
13 | preconditionContainerArray: PreconditionContainerArray
14 | ) {
15 | const permissions = new PermissionsBitField(requiredClientPermissions);
16 | if (permissions.bitfield !== 0n) {
17 | preconditionContainerArray.append({ name: CommandPreConditions.ClientPermissions, context: { permissions } });
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/lib/precondition-resolvers/cooldown.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import type { Command } from '../structures/Command';
3 | import { BucketScope, CommandPreConditions } from '../types/Enums';
4 | import { type PreconditionContainerArray } from '../utils/preconditions/PreconditionContainerArray';
5 |
6 | /**
7 | * Appends the `Cooldown` precondition when {@link Command.Options.cooldownLimit} and
8 | * {@link Command.Options.cooldownDelay} are both non-zero.
9 | *
10 | * @param command The command to parse cooldowns for.
11 | * @param cooldownLimit The cooldown limit to use.
12 | * @param cooldownDelay The cooldown delay to use.
13 | * @param cooldownScope The cooldown scope to use.
14 | * @param cooldownFilteredUsers The cooldown filtered users to use.
15 | * @param preconditionContainerArray The precondition container array to append the precondition to.
16 | */
17 | export function parseConstructorPreConditionsCooldown(
18 | command: Command
,
19 | cooldownLimit: number | undefined,
20 | cooldownDelay: number | undefined,
21 | cooldownScope: BucketScope | undefined,
22 | cooldownFilteredUsers: string[] | undefined,
23 | preconditionContainerArray: PreconditionContainerArray
24 | ) {
25 | const { defaultCooldown } = container.client.options;
26 |
27 | // We will check for whether the command is filtered from the defaults, but we will allow overridden values to
28 | // be set. If an overridden value is passed, it will have priority. Otherwise, it will default to 0 if filtered
29 | // (causing the precondition to not be registered) or the default value with a fallback to a single-use cooldown.
30 | const filtered = defaultCooldown?.filteredCommands?.includes(command.name) ?? false;
31 | const limit = cooldownLimit ?? (filtered ? 0 : (defaultCooldown?.limit ?? 1));
32 | const delay = cooldownDelay ?? (filtered ? 0 : (defaultCooldown?.delay ?? 0));
33 |
34 | if (limit && delay) {
35 | const scope = cooldownScope ?? defaultCooldown?.scope ?? BucketScope.User;
36 | const filteredUsers = cooldownFilteredUsers ?? defaultCooldown?.filteredUsers;
37 | preconditionContainerArray.append({
38 | name: CommandPreConditions.Cooldown,
39 | context: { scope, limit, delay, filteredUsers }
40 | });
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/lib/precondition-resolvers/index.ts:
--------------------------------------------------------------------------------
1 | export * from './clientPermissions';
2 | export * from './cooldown';
3 | export * from './nsfw';
4 | export * from './runIn';
5 | export * from './userPermissions';
6 |
--------------------------------------------------------------------------------
/src/lib/precondition-resolvers/nsfw.ts:
--------------------------------------------------------------------------------
1 | import { CommandPreConditions } from '../types/Enums';
2 | import type { PreconditionContainerArray } from '../utils/preconditions/PreconditionContainerArray';
3 |
4 | /**
5 | * Appends the `NSFW` precondition if {@link SubcommandMappingMethod.nsfw} is set to true.
6 | * @param nsfw Whether this command is NSFW or not.
7 | * @param preconditionContainerArray The precondition container array to append the precondition to.
8 | */
9 | export function parseConstructorPreConditionsNsfw(nsfw: boolean | undefined, preconditionContainerArray: PreconditionContainerArray) {
10 | if (nsfw) preconditionContainerArray.append(CommandPreConditions.NotSafeForWork);
11 | }
12 |
--------------------------------------------------------------------------------
/src/lib/precondition-resolvers/runIn.ts:
--------------------------------------------------------------------------------
1 | import { isNullish } from '@sapphire/utilities';
2 | import type { ChannelType } from 'discord.js';
3 | import { Command } from '../structures/Command';
4 | import type { CommandRunInUnion, CommandSpecificRunIn } from '../types/CommandTypes';
5 | import { CommandPreConditions } from '../types/Enums';
6 | import type { PreconditionContainerArray } from '../utils/preconditions/PreconditionContainerArray';
7 |
8 | /**
9 | * Appends the `RunIn` precondition based on the values passed, defaulting to `null`, which doesn't add a
10 | * precondition.
11 | * @param runIn The command's `runIn` option field from the constructor.
12 | * @param resolveConstructorPreConditionsRunType The function to resolve the run type from the constructor.
13 | * @param preconditionContainerArray The precondition container array to append the precondition to.
14 | */
15 | export function parseConstructorPreConditionsRunIn(
16 | runIn: CommandRunInUnion | CommandSpecificRunIn,
17 | resolveConstructorPreConditionsRunType: (types: CommandRunInUnion) => readonly ChannelType[] | null,
18 | preconditionContainerArray: PreconditionContainerArray
19 | ) {
20 | // Early return if there's no runIn option:
21 | if (isNullish(runIn)) return;
22 |
23 | if (Command.runInTypeIsSpecificsObject(runIn)) {
24 | const messageRunTypes = resolveConstructorPreConditionsRunType(runIn.messageRun);
25 | const chatInputRunTypes = resolveConstructorPreConditionsRunType(runIn.chatInputRun);
26 | const contextMenuRunTypes = resolveConstructorPreConditionsRunType(runIn.contextMenuRun);
27 |
28 | if (messageRunTypes !== null || chatInputRunTypes !== null || contextMenuRunTypes !== null) {
29 | preconditionContainerArray.append({
30 | name: CommandPreConditions.RunIn,
31 | context: {
32 | types: {
33 | messageRun: messageRunTypes ?? [],
34 | chatInputRun: chatInputRunTypes ?? [],
35 | contextMenuRun: contextMenuRunTypes ?? []
36 | }
37 | }
38 | });
39 | }
40 | } else {
41 | const types = resolveConstructorPreConditionsRunType(runIn);
42 | if (types !== null) {
43 | preconditionContainerArray.append({ name: CommandPreConditions.RunIn, context: { types } });
44 | }
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/lib/precondition-resolvers/userPermissions.ts:
--------------------------------------------------------------------------------
1 | import { PermissionsBitField, type PermissionResolvable } from 'discord.js';
2 | import { CommandPreConditions } from '../types/Enums';
3 | import type { PreconditionContainerArray } from '../utils/preconditions/PreconditionContainerArray';
4 |
5 | /**
6 | * Appends the `UserPermissions` precondition when {@link Command.Options.requiredUserPermissions} resolves to a
7 | * non-zero bitfield.
8 | * @param requiredUserPermissions The required user permissions.
9 | * @param preconditionContainerArray The precondition container array to append the precondition to.
10 | */
11 | export function parseConstructorPreConditionsRequiredUserPermissions(
12 | requiredUserPermissions: PermissionResolvable | undefined,
13 | preconditionContainerArray: PreconditionContainerArray
14 | ) {
15 | const permissions = new PermissionsBitField(requiredUserPermissions);
16 | if (permissions.bitfield !== 0n) {
17 | preconditionContainerArray.append({ name: CommandPreConditions.UserPermissions, context: { permissions } });
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/lib/resolvers/boolean.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import { Identifiers } from '../errors/Identifiers';
3 |
4 | const baseTruths = ['1', 'true', '+', 't', 'yes', 'y'] as const;
5 | const baseFalses = ['0', 'false', '-', 'f', 'no', 'n'] as const;
6 |
7 | export function resolveBoolean(
8 | parameter: string,
9 | customs?: { truths?: readonly string[]; falses?: readonly string[] }
10 | ): Result {
11 | const boolean = parameter.toLowerCase();
12 |
13 | if ([...baseTruths, ...(customs?.truths ?? [])].includes(boolean)) {
14 | return Result.ok(true);
15 | }
16 |
17 | if ([...baseFalses, ...(customs?.falses ?? [])].includes(boolean)) {
18 | return Result.ok(false);
19 | }
20 |
21 | return Result.err(Identifiers.ArgumentBooleanError);
22 | }
23 |
--------------------------------------------------------------------------------
/src/lib/resolvers/channel.ts:
--------------------------------------------------------------------------------
1 | import { ChannelMentionRegex, type ChannelTypes } from '@sapphire/discord.js-utilities';
2 | import { container } from '@sapphire/pieces';
3 | import { Result } from '@sapphire/result';
4 | import type { CommandInteraction, Message, Snowflake } from 'discord.js';
5 | import { Identifiers } from '../errors/Identifiers';
6 |
7 | export function resolveChannel(
8 | parameter: string,
9 | messageOrInteraction: Message | CommandInteraction
10 | ): Result {
11 | const channelId = (ChannelMentionRegex.exec(parameter)?.[1] ?? parameter) as Snowflake;
12 | const channel = (messageOrInteraction.guild ? messageOrInteraction.guild.channels : container.client.channels).cache.get(channelId);
13 |
14 | if (channel) {
15 | return Result.ok(channel as ChannelTypes);
16 | }
17 |
18 | return Result.err(Identifiers.ArgumentChannelError);
19 | }
20 |
--------------------------------------------------------------------------------
/src/lib/resolvers/date.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import { Identifiers } from '../errors/Identifiers';
3 |
4 | export function resolveDate(
5 | parameter: string,
6 | options?: { minimum?: number; maximum?: number }
7 | ): Result {
8 | const parsed = new Date(parameter);
9 |
10 | const time = parsed.getTime();
11 |
12 | if (Number.isNaN(time)) {
13 | return Result.err(Identifiers.ArgumentDateError);
14 | }
15 |
16 | if (typeof options?.minimum === 'number' && time < options.minimum) {
17 | return Result.err(Identifiers.ArgumentDateTooEarly);
18 | }
19 |
20 | if (typeof options?.maximum === 'number' && time > options.maximum) {
21 | return Result.err(Identifiers.ArgumentDateTooFar);
22 | }
23 |
24 | return Result.ok(parsed);
25 | }
26 |
--------------------------------------------------------------------------------
/src/lib/resolvers/dmChannel.ts:
--------------------------------------------------------------------------------
1 | import { isDMChannel } from '@sapphire/discord.js-utilities';
2 | import { Result } from '@sapphire/result';
3 | import type { CommandInteraction, DMChannel, Message } from 'discord.js';
4 | import { Identifiers } from '../errors/Identifiers';
5 | import { resolveChannel } from './channel';
6 |
7 | export function resolveDMChannel(
8 | parameter: string,
9 | messageOrInteraction: Message | CommandInteraction
10 | ): Result {
11 | const result = resolveChannel(parameter, messageOrInteraction);
12 | return result.mapInto((value) => {
13 | if (isDMChannel(value) && !value.partial) {
14 | return Result.ok(value);
15 | }
16 |
17 | return Result.err(Identifiers.ArgumentDMChannelError);
18 | });
19 | }
20 |
--------------------------------------------------------------------------------
/src/lib/resolvers/emoji.ts:
--------------------------------------------------------------------------------
1 | import { EmojiRegex, createTwemojiRegex } from '@sapphire/discord-utilities';
2 | import { Result } from '@sapphire/result';
3 | import { parseEmoji } from 'discord.js';
4 | import { Identifiers } from '../errors/Identifiers';
5 |
6 | const TwemojiRegex = createTwemojiRegex();
7 |
8 | export function resolveEmoji(parameter: string): Result {
9 | const twemoji = TwemojiRegex.exec(parameter)?.[0] ?? null;
10 |
11 | TwemojiRegex.lastIndex = 0;
12 |
13 | if (twemoji) {
14 | return Result.ok({
15 | name: twemoji,
16 | id: null
17 | });
18 | }
19 |
20 | const emojiId = EmojiRegex.test(parameter);
21 |
22 | if (emojiId) {
23 | const resolved = parseEmoji(parameter) as EmojiObject | null;
24 |
25 | if (resolved) {
26 | return Result.ok(resolved);
27 | }
28 | }
29 |
30 | return Result.err(Identifiers.ArgumentEmojiError);
31 | }
32 |
33 | export interface EmojiObject {
34 | name: string | null;
35 | id: string | null;
36 | animated?: boolean;
37 | }
38 |
--------------------------------------------------------------------------------
/src/lib/resolvers/enum.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import { Identifiers } from '../errors/Identifiers';
3 |
4 | export function resolveEnum(
5 | parameter: string,
6 | options?: { enum?: string[]; caseInsensitive?: boolean }
7 | ): Result {
8 | if (!options?.enum?.length) {
9 | return Result.err(Identifiers.ArgumentEnumEmptyError);
10 | }
11 |
12 | if (!options.caseInsensitive && !options.enum.includes(parameter)) {
13 | return Result.err(Identifiers.ArgumentEnumError);
14 | }
15 |
16 | if (options.caseInsensitive && !options.enum.some((v) => v.toLowerCase() === parameter.toLowerCase())) {
17 | return Result.err(Identifiers.ArgumentEnumError);
18 | }
19 |
20 | return Result.ok(parameter);
21 | }
22 |
--------------------------------------------------------------------------------
/src/lib/resolvers/float.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import { Identifiers } from '../errors/Identifiers';
3 |
4 | export function resolveFloat(
5 | parameter: string,
6 | options?: { minimum?: number; maximum?: number }
7 | ): Result {
8 | const parsed = Number(parameter);
9 |
10 | if (Number.isNaN(parsed)) {
11 | return Result.err(Identifiers.ArgumentFloatError);
12 | }
13 |
14 | if (typeof options?.minimum === 'number' && parsed < options.minimum) {
15 | return Result.err(Identifiers.ArgumentFloatTooSmall);
16 | }
17 |
18 | if (typeof options?.maximum === 'number' && parsed > options.maximum) {
19 | return Result.err(Identifiers.ArgumentFloatTooLarge);
20 | }
21 |
22 | return Result.ok(parsed);
23 | }
24 |
--------------------------------------------------------------------------------
/src/lib/resolvers/guild.ts:
--------------------------------------------------------------------------------
1 | import { SnowflakeRegex } from '@sapphire/discord-utilities';
2 | import { container } from '@sapphire/pieces';
3 | import { Result } from '@sapphire/result';
4 | import type { Guild } from 'discord.js';
5 | import { Identifiers } from '../errors/Identifiers';
6 |
7 | export async function resolveGuild(parameter: string): Promise> {
8 | const guildId = SnowflakeRegex.exec(parameter)?.groups?.id;
9 | const guild = guildId ? await container.client.guilds.fetch(guildId).catch(() => null) : null;
10 |
11 | if (guild) {
12 | return Result.ok(guild);
13 | }
14 |
15 | return Result.err(Identifiers.ArgumentGuildError);
16 | }
17 |
--------------------------------------------------------------------------------
/src/lib/resolvers/guildCategoryChannel.ts:
--------------------------------------------------------------------------------
1 | import { isCategoryChannel } from '@sapphire/discord.js-utilities';
2 | import type { Result } from '@sapphire/result';
3 | import type { CategoryChannel, Guild } from 'discord.js';
4 | import { Identifiers } from '../errors/Identifiers';
5 | import { resolveGuildChannelPredicate } from '../utils/resolvers/resolveGuildChannelPredicate';
6 |
7 | export function resolveGuildCategoryChannel(
8 | parameter: string,
9 | guild: Guild
10 | ): Result {
11 | return resolveGuildChannelPredicate(parameter, guild, isCategoryChannel, Identifiers.ArgumentGuildCategoryChannelError);
12 | }
13 |
--------------------------------------------------------------------------------
/src/lib/resolvers/guildChannel.ts:
--------------------------------------------------------------------------------
1 | import { ChannelMentionRegex, SnowflakeRegex } from '@sapphire/discord-utilities';
2 | import type { GuildBasedChannelTypes } from '@sapphire/discord.js-utilities';
3 | import { Result } from '@sapphire/result';
4 | import type { Guild, Snowflake } from 'discord.js';
5 | import { Identifiers } from '../errors/Identifiers';
6 |
7 | export function resolveGuildChannel(parameter: string, guild: Guild): Result {
8 | const channel = resolveById(parameter, guild) ?? resolveByQuery(parameter, guild);
9 |
10 | if (channel) {
11 | return Result.ok(channel);
12 | }
13 |
14 | return Result.err(Identifiers.ArgumentGuildChannelError);
15 | }
16 |
17 | function resolveById(argument: string, guild: Guild): GuildBasedChannelTypes | null {
18 | const channelId = ChannelMentionRegex.exec(argument) ?? SnowflakeRegex.exec(argument);
19 | return channelId ? ((guild.channels.cache.get(channelId[1] as Snowflake) as GuildBasedChannelTypes) ?? null) : null;
20 | }
21 |
22 | function resolveByQuery(argument: string, guild: Guild): GuildBasedChannelTypes | null {
23 | const lowerCaseArgument = argument.toLowerCase();
24 | return (guild.channels.cache.find((channel) => channel.name.toLowerCase() === lowerCaseArgument) as GuildBasedChannelTypes) ?? null;
25 | }
26 |
--------------------------------------------------------------------------------
/src/lib/resolvers/guildNewsChannel.ts:
--------------------------------------------------------------------------------
1 | import { isNewsChannel } from '@sapphire/discord.js-utilities';
2 | import type { Result } from '@sapphire/result';
3 | import type { Guild, NewsChannel } from 'discord.js';
4 | import { Identifiers } from '../errors/Identifiers';
5 | import { resolveGuildChannelPredicate } from '../utils/resolvers/resolveGuildChannelPredicate';
6 |
7 | export function resolveGuildNewsChannel(
8 | parameter: string,
9 | guild: Guild
10 | ): Result {
11 | return resolveGuildChannelPredicate(parameter, guild, isNewsChannel, Identifiers.ArgumentGuildNewsChannelError);
12 | }
13 |
--------------------------------------------------------------------------------
/src/lib/resolvers/guildNewsThreadChannel.ts:
--------------------------------------------------------------------------------
1 | import { isNewsThreadChannel } from '@sapphire/discord.js-utilities';
2 | import type { Result } from '@sapphire/result';
3 | import type { Guild, ThreadChannel } from 'discord.js';
4 | import { Identifiers } from '../errors/Identifiers';
5 | import { resolveGuildChannelPredicate } from '../utils/resolvers/resolveGuildChannelPredicate';
6 |
7 | export function resolveGuildNewsThreadChannel(
8 | parameter: string,
9 | guild: Guild
10 | ): Result<
11 | ThreadChannel,
12 | Identifiers.ArgumentGuildChannelError | Identifiers.ArgumentGuildThreadChannelError | Identifiers.ArgumentGuildNewsThreadChannelError
13 | > {
14 | return resolveGuildChannelPredicate(parameter, guild, isNewsThreadChannel, Identifiers.ArgumentGuildNewsThreadChannelError);
15 | }
16 |
--------------------------------------------------------------------------------
/src/lib/resolvers/guildPrivateThreadChannel.ts:
--------------------------------------------------------------------------------
1 | import { isPrivateThreadChannel } from '@sapphire/discord.js-utilities';
2 | import type { Result } from '@sapphire/result';
3 | import type { Guild, ThreadChannel } from 'discord.js';
4 | import { Identifiers } from '../errors/Identifiers';
5 | import { resolveGuildChannelPredicate } from '../utils/resolvers/resolveGuildChannelPredicate';
6 |
7 | export function resolveGuildPrivateThreadChannel(
8 | parameter: string,
9 | guild: Guild
10 | ): Result<
11 | ThreadChannel,
12 | Identifiers.ArgumentGuildChannelError | Identifiers.ArgumentGuildThreadChannelError | Identifiers.ArgumentGuildPrivateThreadChannelError
13 | > {
14 | return resolveGuildChannelPredicate(parameter, guild, isPrivateThreadChannel, Identifiers.ArgumentGuildPrivateThreadChannelError);
15 | }
16 |
--------------------------------------------------------------------------------
/src/lib/resolvers/guildPublicThreadChannel.ts:
--------------------------------------------------------------------------------
1 | import { isPublicThreadChannel } from '@sapphire/discord.js-utilities';
2 | import type { Result } from '@sapphire/result';
3 | import type { Guild, ThreadChannel } from 'discord.js';
4 | import { Identifiers } from '../errors/Identifiers';
5 | import { resolveGuildChannelPredicate } from '../utils/resolvers/resolveGuildChannelPredicate';
6 |
7 | export function resolveGuildPublicThreadChannel(
8 | parameter: string,
9 | guild: Guild
10 | ): Result<
11 | ThreadChannel,
12 | Identifiers.ArgumentGuildChannelError | Identifiers.ArgumentGuildThreadChannelError | Identifiers.ArgumentGuildPublicThreadChannelError
13 | > {
14 | return resolveGuildChannelPredicate(parameter, guild, isPublicThreadChannel, Identifiers.ArgumentGuildPublicThreadChannelError);
15 | }
16 |
--------------------------------------------------------------------------------
/src/lib/resolvers/guildStageVoiceChannel.ts:
--------------------------------------------------------------------------------
1 | import { isStageChannel } from '@sapphire/discord.js-utilities';
2 | import type { Result } from '@sapphire/result';
3 | import type { Guild, StageChannel } from 'discord.js';
4 | import { Identifiers } from '../errors/Identifiers';
5 | import { resolveGuildChannelPredicate } from '../utils/resolvers/resolveGuildChannelPredicate';
6 |
7 | export function resolveGuildStageVoiceChannel(
8 | parameter: string,
9 | guild: Guild
10 | ): Result {
11 | return resolveGuildChannelPredicate(parameter, guild, isStageChannel, Identifiers.ArgumentGuildStageVoiceChannelError);
12 | }
13 |
--------------------------------------------------------------------------------
/src/lib/resolvers/guildTextChannel.ts:
--------------------------------------------------------------------------------
1 | import { isTextChannel } from '@sapphire/discord.js-utilities';
2 | import type { Result } from '@sapphire/result';
3 | import type { Guild, TextChannel } from 'discord.js';
4 | import { Identifiers } from '../errors/Identifiers';
5 | import { resolveGuildChannelPredicate } from '../utils/resolvers/resolveGuildChannelPredicate';
6 |
7 | export function resolveGuildTextChannel(
8 | parameter: string,
9 | guild: Guild
10 | ): Result {
11 | return resolveGuildChannelPredicate(parameter, guild, isTextChannel, Identifiers.ArgumentGuildTextChannelError);
12 | }
13 |
--------------------------------------------------------------------------------
/src/lib/resolvers/guildThreadChannel.ts:
--------------------------------------------------------------------------------
1 | import { isThreadChannel } from '@sapphire/discord.js-utilities';
2 | import type { Result } from '@sapphire/result';
3 | import type { Guild, ThreadChannel } from 'discord.js';
4 | import { Identifiers } from '../errors/Identifiers';
5 | import { resolveGuildChannelPredicate } from '../utils/resolvers/resolveGuildChannelPredicate';
6 |
7 | export function resolveGuildThreadChannel(
8 | parameter: string,
9 | guild: Guild
10 | ): Result {
11 | return resolveGuildChannelPredicate(parameter, guild, isThreadChannel, Identifiers.ArgumentGuildThreadChannelError);
12 | }
13 |
--------------------------------------------------------------------------------
/src/lib/resolvers/guildVoiceChannel.ts:
--------------------------------------------------------------------------------
1 | import { isVoiceChannel } from '@sapphire/discord.js-utilities';
2 | import type { Result } from '@sapphire/result';
3 | import type { Guild, VoiceChannel } from 'discord.js';
4 | import { Identifiers } from '../errors/Identifiers';
5 | import { resolveGuildChannelPredicate } from '../utils/resolvers/resolveGuildChannelPredicate';
6 |
7 | export function resolveGuildVoiceChannel(
8 | parameter: string,
9 | guild: Guild
10 | ): Result {
11 | return resolveGuildChannelPredicate(parameter, guild, isVoiceChannel, Identifiers.ArgumentGuildVoiceChannelError);
12 | }
13 |
--------------------------------------------------------------------------------
/src/lib/resolvers/hyperlink.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import { URL } from 'node:url';
3 | import { Identifiers } from '../errors/Identifiers';
4 |
5 | export function resolveHyperlink(parameter: string): Result {
6 | const result = Result.from(() => new URL(parameter));
7 | return result.mapErr(() => Identifiers.ArgumentHyperlinkError) as Result;
8 | }
9 |
--------------------------------------------------------------------------------
/src/lib/resolvers/index.ts:
--------------------------------------------------------------------------------
1 | export * from './boolean';
2 | export * from './channel';
3 | export * from './date';
4 | export * from './dmChannel';
5 | export { resolveEmoji } from './emoji';
6 | export * from './enum';
7 | export * from './float';
8 | export * from './guild';
9 | export * from './guildCategoryChannel';
10 | export * from './guildChannel';
11 | export * from './guildNewsChannel';
12 | export * from './guildNewsThreadChannel';
13 | export * from './guildPrivateThreadChannel';
14 | export * from './guildPublicThreadChannel';
15 | export * from './guildStageVoiceChannel';
16 | export * from './guildTextChannel';
17 | export * from './guildThreadChannel';
18 | export * from './guildVoiceChannel';
19 | export * from './hyperlink';
20 | export * from './integer';
21 | export * from './member';
22 | export { resolveMessage } from './message';
23 | export * from './number';
24 | export * from './partialDMChannel';
25 | export * from './role';
26 | export * from './string';
27 | export * from './user';
28 |
--------------------------------------------------------------------------------
/src/lib/resolvers/integer.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import { Identifiers } from '../errors/Identifiers';
3 |
4 | export function resolveInteger(
5 | parameter: string,
6 | options?: { minimum?: number; maximum?: number }
7 | ): Result {
8 | const parsed = Number(parameter);
9 |
10 | if (!Number.isInteger(parsed)) {
11 | return Result.err(Identifiers.ArgumentIntegerError);
12 | }
13 |
14 | if (typeof options?.minimum === 'number' && parsed < options.minimum) {
15 | return Result.err(Identifiers.ArgumentIntegerTooSmall);
16 | }
17 |
18 | if (typeof options?.maximum === 'number' && parsed > options.maximum) {
19 | return Result.err(Identifiers.ArgumentIntegerTooLarge);
20 | }
21 |
22 | return Result.ok(parsed);
23 | }
24 |
--------------------------------------------------------------------------------
/src/lib/resolvers/member.ts:
--------------------------------------------------------------------------------
1 | import { SnowflakeRegex, UserOrMemberMentionRegex } from '@sapphire/discord-utilities';
2 | import { Result } from '@sapphire/result';
3 | import { isNullish } from '@sapphire/utilities';
4 | import type { Guild, GuildMember, Snowflake } from 'discord.js';
5 | import { Identifiers } from '../errors/Identifiers';
6 |
7 | export async function resolveMember(
8 | parameter: string,
9 | guild: Guild,
10 | performFuzzySearch?: boolean
11 | ): Promise> {
12 | let member = await resolveById(parameter, guild);
13 |
14 | if (isNullish(member) && performFuzzySearch) {
15 | member = await resolveByQuery(parameter, guild);
16 | }
17 |
18 | if (member) {
19 | return Result.ok(member);
20 | }
21 |
22 | return Result.err(Identifiers.ArgumentMemberError);
23 | }
24 |
25 | async function resolveById(argument: string, guild: Guild): Promise {
26 | const memberId = UserOrMemberMentionRegex.exec(argument) ?? SnowflakeRegex.exec(argument);
27 | return memberId ? guild.members.fetch(memberId[1] as Snowflake).catch(() => null) : null;
28 | }
29 |
30 | async function resolveByQuery(argument: string, guild: Guild): Promise {
31 | argument = argument.length > 5 && argument.at(-5) === '#' ? argument.slice(0, -5) : argument;
32 |
33 | const members = await guild.members.fetch({ query: argument, limit: 1 }).catch(() => null);
34 | return members?.first() ?? null;
35 | }
36 |
--------------------------------------------------------------------------------
/src/lib/resolvers/number.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import { Identifiers } from '../errors/Identifiers';
3 |
4 | export function resolveNumber(
5 | parameter: string,
6 | options?: { minimum?: number; maximum?: number }
7 | ): Result {
8 | const parsed = Number(parameter);
9 | if (Number.isNaN(parsed)) {
10 | return Result.err(Identifiers.ArgumentNumberError);
11 | }
12 |
13 | if (typeof options?.minimum === 'number' && parsed < options.minimum) {
14 | return Result.err(Identifiers.ArgumentNumberTooSmall);
15 | }
16 |
17 | if (typeof options?.maximum === 'number' && parsed > options.maximum) {
18 | return Result.err(Identifiers.ArgumentNumberTooLarge);
19 | }
20 |
21 | return Result.ok(parsed);
22 | }
23 |
--------------------------------------------------------------------------------
/src/lib/resolvers/partialDMChannel.ts:
--------------------------------------------------------------------------------
1 | import { isDMChannel } from '@sapphire/discord.js-utilities';
2 | import { Result } from '@sapphire/result';
3 | import type { DMChannel, Message, PartialDMChannel } from 'discord.js';
4 | import { Identifiers } from '../errors/Identifiers';
5 | import { resolveChannel } from './channel';
6 |
7 | export function resolvePartialDMChannel(
8 | parameter: string,
9 | message: Message
10 | ): Result {
11 | const result = resolveChannel(parameter, message);
12 | return result.mapInto((channel) => {
13 | if (isDMChannel(channel)) {
14 | return Result.ok(channel);
15 | }
16 |
17 | return Result.err(Identifiers.ArgumentDMChannelError);
18 | });
19 | }
20 |
--------------------------------------------------------------------------------
/src/lib/resolvers/role.ts:
--------------------------------------------------------------------------------
1 | import { RoleMentionRegex, SnowflakeRegex } from '@sapphire/discord-utilities';
2 | import { Result } from '@sapphire/result';
3 | import type { Guild, Role, Snowflake } from 'discord.js';
4 | import { Identifiers } from '../errors/Identifiers';
5 |
6 | export async function resolveRole(parameter: string, guild: Guild): Promise> {
7 | const role = (await resolveById(parameter, guild)) ?? resolveByQuery(parameter, guild);
8 |
9 | if (role) {
10 | return Result.ok(role);
11 | }
12 |
13 | return Result.err(Identifiers.ArgumentRoleError);
14 | }
15 |
16 | async function resolveById(argument: string, guild: Guild): Promise {
17 | const roleId = RoleMentionRegex.exec(argument) ?? SnowflakeRegex.exec(argument);
18 | return roleId ? guild.roles.fetch(roleId[1] as Snowflake) : null;
19 | }
20 |
21 | function resolveByQuery(argument: string, guild: Guild): Role | null {
22 | const lowerCaseArgument = argument.toLowerCase();
23 | return guild.roles.cache.find((role) => role.name.toLowerCase() === lowerCaseArgument) ?? null;
24 | }
25 |
--------------------------------------------------------------------------------
/src/lib/resolvers/string.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import { Identifiers } from '../errors/Identifiers';
3 |
4 | export function resolveString(
5 | parameter: string,
6 | options?: { minimum?: number; maximum?: number }
7 | ): Result {
8 | if (typeof options?.minimum === 'number' && parameter.length < options.minimum) {
9 | return Result.err(Identifiers.ArgumentStringTooShort);
10 | }
11 |
12 | if (typeof options?.maximum === 'number' && parameter.length > options.maximum) {
13 | return Result.err(Identifiers.ArgumentStringTooLong);
14 | }
15 |
16 | return Result.ok(parameter);
17 | }
18 |
--------------------------------------------------------------------------------
/src/lib/resolvers/user.ts:
--------------------------------------------------------------------------------
1 | import { SnowflakeRegex, UserOrMemberMentionRegex } from '@sapphire/discord-utilities';
2 | import { container } from '@sapphire/pieces';
3 | import { Result } from '@sapphire/result';
4 | import type { Snowflake, User } from 'discord.js';
5 | import { Identifiers } from '../errors/Identifiers';
6 |
7 | export async function resolveUser(parameter: string): Promise> {
8 | const userId = UserOrMemberMentionRegex.exec(parameter) ?? SnowflakeRegex.exec(parameter);
9 | const user = userId ? await container.client.users.fetch(userId[1] as Snowflake).catch(() => null) : null;
10 |
11 | if (user) {
12 | return Result.ok(user);
13 | }
14 |
15 | return Result.err(Identifiers.ArgumentUserError);
16 | }
17 |
--------------------------------------------------------------------------------
/src/lib/structures/ArgumentStore.ts:
--------------------------------------------------------------------------------
1 | import { AliasStore } from '@sapphire/pieces';
2 | import { Argument } from './Argument';
3 |
4 | export class ArgumentStore extends AliasStore {
5 | public constructor() {
6 | super(Argument, { name: 'arguments' });
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/lib/structures/ListenerLoaderStrategy.ts:
--------------------------------------------------------------------------------
1 | import { LoaderStrategy } from '@sapphire/pieces';
2 | import type { Listener } from './Listener';
3 | import type { ListenerStore } from './ListenerStore';
4 |
5 | export class ListenerLoaderStrategy extends LoaderStrategy {
6 | public override onLoad(_store: ListenerStore, piece: Listener) {
7 | const listenerCallback = piece['_listener'];
8 | if (listenerCallback) {
9 | const emitter = piece.emitter!;
10 |
11 | // Increment the maximum amount of listeners by one:
12 | const maxListeners = emitter.getMaxListeners();
13 | if (maxListeners !== 0) emitter.setMaxListeners(maxListeners + 1);
14 |
15 | emitter[piece.once ? 'once' : 'on'](piece.event, listenerCallback);
16 | }
17 | }
18 |
19 | public override onUnload(_store: ListenerStore, piece: Listener) {
20 | const listenerCallback = piece['_listener'];
21 | if (!piece.once && listenerCallback) {
22 | const emitter = piece.emitter!;
23 |
24 | // Increment the maximum amount of listeners by one:
25 | const maxListeners = emitter.getMaxListeners();
26 | if (maxListeners !== 0) emitter.setMaxListeners(maxListeners - 1);
27 |
28 | emitter.off(piece.event, listenerCallback);
29 | piece['_listener'] = null;
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/lib/structures/ListenerStore.ts:
--------------------------------------------------------------------------------
1 | import { Store } from '@sapphire/pieces';
2 | import { Listener } from './Listener';
3 | import { ListenerLoaderStrategy } from './ListenerLoaderStrategy';
4 |
5 | export class ListenerStore extends Store {
6 | public constructor() {
7 | super(Listener, { name: 'listeners', strategy: new ListenerLoaderStrategy() });
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/lib/types/ArgumentContexts.ts:
--------------------------------------------------------------------------------
1 | import type { MessageResolverOptions } from '../resolvers/message';
2 | import type { Argument } from '../structures/Argument';
3 |
4 | /**
5 | * The context for the `'enum'` argument.
6 | * @since 4.2.0 (🌿)
7 | */
8 | export interface EnumArgumentContext extends Argument.Context {
9 | readonly enum?: string[];
10 | readonly caseInsensitive?: boolean;
11 | }
12 |
13 | /**
14 | * The context for the `'boolean'` argument.
15 | * @since 4.2.0 (🌿)
16 | */
17 | export interface BooleanArgumentContext extends Argument.Context {
18 | /**
19 | * The words that resolve to `true`.
20 | * Any words added to this array will be merged with the words:
21 | * ```ts
22 | * ['1', 'true', '+', 't', 'yes', 'y']
23 | * ```
24 | */
25 | readonly truths?: string[];
26 | /**
27 | * The words that resolve to `false`.
28 | * Any words added to this array will be merged with the words:
29 | * ```ts
30 | * ['0', 'false', '-', 'f', 'no', 'n']
31 | * ```
32 | */
33 | readonly falses?: string[];
34 | }
35 |
36 | /**
37 | * The context for the `'member'` argument.
38 | * @since 4.2.0 (🌿)
39 | */
40 | export interface MemberArgumentContext extends Argument.Context {
41 | /**
42 | * Whether to perform a fuzzy search with the given argument.
43 | * This will leverage `FetchMembersOptions.query` to do the fuzzy searching.
44 | * @default true
45 | */
46 | readonly performFuzzySearch?: boolean;
47 | }
48 |
49 | /**
50 | * The context for the `'message'` argument.
51 | * @since 4.2.0 (🌿)
52 | */
53 | export type MessageArgumentContext = Omit & Argument.Context;
54 |
--------------------------------------------------------------------------------
/src/lib/utils/application-commands/compute-differences/_shared.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ApplicationCommandOptionType,
3 | ApplicationCommandType,
4 | type APIApplicationCommandChannelOption,
5 | type APIApplicationCommandIntegerOption,
6 | type APIApplicationCommandNumberOption,
7 | type APIApplicationCommandOption,
8 | type APIApplicationCommandStringOption,
9 | type APIApplicationCommandSubcommandGroupOption,
10 | type APIApplicationCommandSubcommandOption
11 | } from 'discord-api-types/v10';
12 |
13 | export const optionTypeToPrettyName = new Map([
14 | [ApplicationCommandOptionType.Subcommand, 'subcommand'],
15 | [ApplicationCommandOptionType.SubcommandGroup, 'subcommand group'],
16 | [ApplicationCommandOptionType.String, 'string option'],
17 | [ApplicationCommandOptionType.Integer, 'integer option'],
18 | [ApplicationCommandOptionType.Boolean, 'boolean option'],
19 | [ApplicationCommandOptionType.User, 'user option'],
20 | [ApplicationCommandOptionType.Channel, 'channel option'],
21 | [ApplicationCommandOptionType.Role, 'role option'],
22 | [ApplicationCommandOptionType.Mentionable, 'mentionable option'],
23 | [ApplicationCommandOptionType.Number, 'number option'],
24 | [ApplicationCommandOptionType.Attachment, 'attachment option']
25 | ]);
26 |
27 | export const contextMenuTypes = [ApplicationCommandType.Message, ApplicationCommandType.User];
28 | export const subcommandTypes = [ApplicationCommandOptionType.SubcommandGroup, ApplicationCommandOptionType.Subcommand];
29 |
30 | export type APIApplicationCommandSubcommandTypes = APIApplicationCommandSubcommandOption | APIApplicationCommandSubcommandGroupOption;
31 | export type APIApplicationCommandMinAndMaxValueTypes = APIApplicationCommandIntegerOption | APIApplicationCommandNumberOption;
32 | export type APIApplicationCommandChoosableAndAutocompletableTypes = APIApplicationCommandMinAndMaxValueTypes | APIApplicationCommandStringOption;
33 | export type APIApplicationCommandMinMaxLengthTypes = APIApplicationCommandStringOption;
34 |
35 | export function hasMinMaxValueSupport(option: APIApplicationCommandOption): option is APIApplicationCommandMinAndMaxValueTypes {
36 | return [ApplicationCommandOptionType.Integer, ApplicationCommandOptionType.Number].includes(option.type);
37 | }
38 |
39 | export function hasChoicesAndAutocompleteSupport(
40 | option: APIApplicationCommandOption
41 | ): option is APIApplicationCommandChoosableAndAutocompletableTypes {
42 | return [
43 | ApplicationCommandOptionType.Integer, //
44 | ApplicationCommandOptionType.Number,
45 | ApplicationCommandOptionType.String
46 | ].includes(option.type);
47 | }
48 |
49 | export function hasMinMaxLengthSupport(option: APIApplicationCommandOption): option is APIApplicationCommandMinMaxLengthTypes {
50 | return option.type === ApplicationCommandOptionType.String;
51 | }
52 |
53 | export function hasChannelTypesSupport(option: APIApplicationCommandOption): option is APIApplicationCommandChannelOption {
54 | return option.type === ApplicationCommandOptionType.Channel;
55 | }
56 |
57 | export interface CommandDifference {
58 | key: string;
59 | expected: string;
60 | original: string;
61 | }
62 |
--------------------------------------------------------------------------------
/src/lib/utils/application-commands/compute-differences/contexts.ts:
--------------------------------------------------------------------------------
1 | import type { InteractionContextType } from 'discord.js';
2 | import type { CommandDifference } from './_shared';
3 |
4 | export function* checkInteractionContextTypes(
5 | existingContexts?: InteractionContextType[],
6 | newContexts?: InteractionContextType[]
7 | ): Generator {
8 | // 0. No existing contexts and now we have contexts
9 | if (!existingContexts && newContexts?.length) {
10 | yield {
11 | key: 'contexts',
12 | original: 'no contexts present',
13 | expected: 'contexts present'
14 | };
15 | }
16 | // 1. Existing contexts and now we have no contexts
17 | else if (existingContexts?.length && !newContexts?.length) {
18 | yield {
19 | key: 'contexts',
20 | original: 'contexts present',
21 | expected: 'no contexts present'
22 | };
23 | }
24 | // 2. Maybe changes in order or additions, log
25 | else if (newContexts?.length) {
26 | let index = 0;
27 |
28 | for (const newContext of newContexts) {
29 | const currentIndex = index++;
30 |
31 | if (existingContexts![currentIndex] !== newContext) {
32 | yield {
33 | key: `contexts[${currentIndex}]`,
34 | original: `contexts type ${existingContexts?.[currentIndex]}`,
35 | expected: `contexts type ${newContext}`
36 | };
37 | }
38 | }
39 |
40 | if (index < existingContexts!.length) {
41 | let type: InteractionContextType;
42 |
43 | while ((type = existingContexts![index]) !== undefined) {
44 | yield {
45 | key: `contexts[${index}]`,
46 | original: `context ${type} present`,
47 | expected: `no context present`
48 | };
49 |
50 | index++;
51 | }
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/lib/utils/application-commands/compute-differences/default_member_permissions.ts:
--------------------------------------------------------------------------------
1 | import type { CommandDifference } from './_shared';
2 |
3 | export function* checkDefaultMemberPermissions(oldPermissions?: string | null, newPermissions?: string | null): Generator {
4 | if (oldPermissions !== newPermissions) {
5 | yield {
6 | key: 'defaultMemberPermissions',
7 | original: String(oldPermissions),
8 | expected: String(newPermissions)
9 | };
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/lib/utils/application-commands/compute-differences/description.ts:
--------------------------------------------------------------------------------
1 | import type { CommandDifference } from './_shared';
2 |
3 | export function* checkDescription({
4 | oldDescription,
5 | newDescription,
6 | key = 'description'
7 | }: {
8 | oldDescription: string;
9 | newDescription: string;
10 | key?: string;
11 | }): Generator {
12 | if (oldDescription !== newDescription) {
13 | yield {
14 | key,
15 | original: oldDescription,
16 | expected: newDescription
17 | };
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/lib/utils/application-commands/compute-differences/dm_permission.ts:
--------------------------------------------------------------------------------
1 | import type { CommandDifference } from './_shared';
2 |
3 | export function* checkDMPermission(oldDmPermission?: boolean, newDmPermission?: boolean): Generator {
4 | if ((oldDmPermission ?? true) !== (newDmPermission ?? true)) {
5 | yield {
6 | key: 'dmPermission',
7 | original: String(oldDmPermission ?? true),
8 | expected: String(newDmPermission ?? true)
9 | };
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/lib/utils/application-commands/compute-differences/integration_types.ts:
--------------------------------------------------------------------------------
1 | import type { ApplicationIntegrationType } from 'discord.js';
2 | import type { CommandDifference } from './_shared';
3 |
4 | export function* checkIntegrationTypes(
5 | existingIntegrationTypes?: ApplicationIntegrationType[],
6 | newIntegrationTypes?: ApplicationIntegrationType[]
7 | ): Generator {
8 | // 0. No existing integration types and now we have integration types
9 | if (!existingIntegrationTypes?.length && newIntegrationTypes?.length) {
10 | yield {
11 | key: 'integrationTypes',
12 | original: 'no integration types present',
13 | expected: 'integration types present'
14 | };
15 | }
16 | // 1. Existing integration types and now we have no integration types
17 | else if (existingIntegrationTypes?.length && !newIntegrationTypes?.length) {
18 | yield {
19 | key: 'integrationTypes',
20 | original: 'integration types present',
21 | expected: 'no integration types present'
22 | };
23 | }
24 | // 2. Maybe changes in order or additions, log
25 | else if (newIntegrationTypes?.length) {
26 | let index = 0;
27 |
28 | for (const newIntegrationType of newIntegrationTypes) {
29 | const currentIndex = index++;
30 |
31 | if (existingIntegrationTypes![currentIndex] !== newIntegrationType) {
32 | yield {
33 | key: `integrationTypes[${currentIndex}]`,
34 | original: `integration type ${existingIntegrationTypes?.[currentIndex]}`,
35 | expected: `integration type ${newIntegrationType}`
36 | };
37 | }
38 | }
39 |
40 | if (index < existingIntegrationTypes!.length) {
41 | let type: ApplicationIntegrationType;
42 |
43 | while ((type = existingIntegrationTypes![index]) !== undefined) {
44 | yield {
45 | key: `integrationTypes[${index}]`,
46 | original: `integration type ${type} present`,
47 | expected: 'no integration type present'
48 | };
49 |
50 | index++;
51 | }
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/lib/utils/application-commands/compute-differences/localizations.ts:
--------------------------------------------------------------------------------
1 | import type { LocalizationMap } from 'discord-api-types/v10';
2 | import type { CommandDifference } from './_shared';
3 |
4 | export function* checkLocalizations({
5 | localeMapName,
6 | localePresentMessage,
7 | localeMissingMessage,
8 | originalLocalizedDescriptions,
9 | expectedLocalizedDescriptions
10 | }: {
11 | localeMapName: string;
12 | localePresentMessage: string;
13 | localeMissingMessage: string;
14 | originalLocalizedDescriptions?: LocalizationMap | null;
15 | expectedLocalizedDescriptions?: LocalizationMap | null;
16 | }) {
17 | if (!originalLocalizedDescriptions && expectedLocalizedDescriptions) {
18 | yield {
19 | key: localeMapName,
20 | original: localeMissingMessage,
21 | expected: localePresentMessage
22 | };
23 | } else if (originalLocalizedDescriptions && !expectedLocalizedDescriptions) {
24 | yield {
25 | key: localeMapName,
26 | original: localePresentMessage,
27 | expected: localeMissingMessage
28 | };
29 | } else if (originalLocalizedDescriptions && expectedLocalizedDescriptions) {
30 | yield* reportLocalizationMapDifferences(originalLocalizedDescriptions, expectedLocalizedDescriptions, localeMapName);
31 | }
32 | }
33 |
34 | function* reportLocalizationMapDifferences(
35 | originalMap: LocalizationMap,
36 | expectedMap: LocalizationMap,
37 | mapName: string
38 | ): Generator {
39 | const originalLocalizations = new Map(Object.entries(originalMap));
40 |
41 | for (const [key, value] of Object.entries(expectedMap)) {
42 | const possiblyExistingEntry = originalLocalizations.get(key) as string | undefined;
43 | originalLocalizations.delete(key);
44 |
45 | const wasMissingBefore = typeof possiblyExistingEntry === 'undefined';
46 | const isResetNow = value === null;
47 |
48 | // Was missing before and now is present
49 | if (wasMissingBefore && !isResetNow) {
50 | yield {
51 | key: `${mapName}.${key}`,
52 | original: 'no localization present',
53 | expected: value
54 | };
55 | }
56 | // Was present before and now is reset
57 | else if (!wasMissingBefore && isResetNow) {
58 | yield {
59 | key: `${mapName}.${key}`,
60 | original: possiblyExistingEntry,
61 | expected: 'no localization present'
62 | };
63 | }
64 | // Not equal
65 | // eslint-disable-next-line no-negated-condition
66 | else if (possiblyExistingEntry !== value) {
67 | yield {
68 | key: `${mapName}.${key}`,
69 | original: String(possiblyExistingEntry),
70 | expected: String(value)
71 | };
72 | }
73 | }
74 |
75 | // Report any remaining localizations
76 | for (const [key, value] of originalLocalizations) {
77 | if (value) {
78 | yield {
79 | key: `${mapName}.${key}`,
80 | original: value,
81 | expected: 'no localization present'
82 | };
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/lib/utils/application-commands/compute-differences/name.ts:
--------------------------------------------------------------------------------
1 | import type { CommandDifference } from './_shared';
2 |
3 | export function* checkName({ oldName, newName, key = 'name' }: { oldName: string; newName: string; key?: string }): Generator {
4 | if (oldName !== newName) {
5 | yield {
6 | key,
7 | original: oldName,
8 | expected: newName
9 | };
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/lib/utils/application-commands/compute-differences/option/minMaxLength.ts:
--------------------------------------------------------------------------------
1 | import type { APIApplicationCommandStringOption } from 'discord-api-types/v10';
2 | import type { CommandDifference } from '../_shared';
3 |
4 | export function* handleMinMaxLengthOptions({
5 | currentIndex,
6 | existingOption,
7 | expectedOption,
8 | keyPath
9 | }: {
10 | currentIndex: number;
11 | keyPath: (index: number) => string;
12 | expectedOption: APIApplicationCommandStringOption;
13 | existingOption: APIApplicationCommandStringOption;
14 | }): Generator {
15 | // 0. No min_length and now we have min_length
16 | if (existingOption.min_length === undefined && expectedOption.min_length !== undefined) {
17 | yield {
18 | key: `${keyPath(currentIndex)}.min_length`,
19 | expected: 'min_length present',
20 | original: 'no min_length present'
21 | };
22 | }
23 | // 1. Have min_length and now we don't
24 | else if (existingOption.min_length !== undefined && expectedOption.min_length === undefined) {
25 | yield {
26 | key: `${keyPath(currentIndex)}.min_length`,
27 | expected: 'no min_length present',
28 | original: 'min_length present'
29 | };
30 | }
31 | // 2. Equality check
32 | else if (existingOption.min_length !== expectedOption.min_length) {
33 | yield {
34 | key: `${keyPath(currentIndex)}.min_length`,
35 | original: String(existingOption.min_length),
36 | expected: String(expectedOption.min_length)
37 | };
38 | }
39 |
40 | // 0. No max_length and now we have max_length
41 | if (existingOption.max_length === undefined && expectedOption.max_length !== undefined) {
42 | yield {
43 | key: `${keyPath(currentIndex)}.max_length`,
44 | expected: 'max_length present',
45 | original: 'no max_length present'
46 | };
47 | }
48 | // 1. Have max_length and now we don't
49 | else if (existingOption.max_length !== undefined && expectedOption.max_length === undefined) {
50 | yield {
51 | key: `${keyPath(currentIndex)}.max_length`,
52 | expected: 'no max_length present',
53 | original: 'max_length present'
54 | };
55 | }
56 | // 2. Equality check
57 | else if (existingOption.max_length !== expectedOption.max_length) {
58 | yield {
59 | key: `${keyPath(currentIndex)}.max_length`,
60 | original: String(existingOption.max_length),
61 | expected: String(expectedOption.max_length)
62 | };
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/lib/utils/application-commands/compute-differences/option/minMaxValue.ts:
--------------------------------------------------------------------------------
1 | import type { APIApplicationCommandMinAndMaxValueTypes, CommandDifference } from '../_shared';
2 |
3 | export function* handleMinMaxValueOptions({
4 | currentIndex,
5 | existingOption,
6 | expectedOption,
7 | keyPath
8 | }: {
9 | currentIndex: number;
10 | keyPath: (index: number) => string;
11 | expectedOption: APIApplicationCommandMinAndMaxValueTypes;
12 | existingOption: APIApplicationCommandMinAndMaxValueTypes;
13 | }): Generator {
14 | // 0. No min_value and now we have min_value
15 | if (existingOption.min_value === undefined && expectedOption.min_value !== undefined) {
16 | yield {
17 | key: `${keyPath(currentIndex)}.min_value`,
18 | expected: 'min_value present',
19 | original: 'no min_value present'
20 | };
21 | }
22 | // 1. Have min_value and now we don't
23 | else if (existingOption.min_value !== undefined && expectedOption.min_value === undefined) {
24 | yield {
25 | key: `${keyPath(currentIndex)}.min_value`,
26 | expected: 'no min_value present',
27 | original: 'min_value present'
28 | };
29 | }
30 | // 2. Equality check
31 | else if (existingOption.min_value !== expectedOption.min_value) {
32 | yield {
33 | key: `${keyPath(currentIndex)}.min_value`,
34 | original: String(existingOption.min_value),
35 | expected: String(expectedOption.min_value)
36 | };
37 | }
38 |
39 | // 0. No max_value and now we have max_value
40 | if (existingOption.max_value === undefined && expectedOption.max_value !== undefined) {
41 | yield {
42 | key: `${keyPath(currentIndex)}.max_value`,
43 | expected: 'max_value present',
44 | original: 'no max_value present'
45 | };
46 | }
47 | // 1. Have max_value and now we don't
48 | else if (existingOption.max_value !== undefined && expectedOption.max_value === undefined) {
49 | yield {
50 | key: `${keyPath(currentIndex)}.max_value`,
51 | expected: 'no max_value present',
52 | original: 'max_value present'
53 | };
54 | }
55 | // 2. Equality check
56 | else if (existingOption.max_value !== expectedOption.max_value) {
57 | yield {
58 | key: `${keyPath(currentIndex)}.max_value`,
59 | original: String(existingOption.max_value),
60 | expected: String(expectedOption.max_value)
61 | };
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/lib/utils/application-commands/compute-differences/option/required.ts:
--------------------------------------------------------------------------------
1 | import type { CommandDifference } from '../_shared';
2 |
3 | export function* checkOptionRequired({
4 | oldRequired,
5 | newRequired,
6 | key
7 | }: {
8 | oldRequired?: boolean;
9 | newRequired?: boolean;
10 | key: string;
11 | }): Generator {
12 | if ((oldRequired ?? false) !== (newRequired ?? false)) {
13 | yield {
14 | key,
15 | original: String(oldRequired ?? false),
16 | expected: String(newRequired ?? false)
17 | };
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/lib/utils/application-commands/compute-differences/option/type.ts:
--------------------------------------------------------------------------------
1 | import type { ApplicationCommandOptionType } from 'discord-api-types/v10';
2 | import { optionTypeToPrettyName, type CommandDifference } from '../_shared';
3 |
4 | export function* checkOptionType({
5 | key,
6 | expectedType,
7 | originalType
8 | }: {
9 | key: string;
10 | originalType: ApplicationCommandOptionType;
11 | expectedType: ApplicationCommandOptionType;
12 | }): Generator {
13 | const expectedTypeString =
14 | optionTypeToPrettyName.get(expectedType) ?? `unknown (${expectedType}); please contact Sapphire developers about this!`;
15 |
16 | if (originalType !== expectedType) {
17 | yield {
18 | key,
19 | original: optionTypeToPrettyName.get(originalType) ?? `unknown (${originalType}); please contact Sapphire developers about this!`,
20 | expected: expectedTypeString
21 | };
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/lib/utils/application-commands/getNeededParameters.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import type { ApplicationCommand, ApplicationCommandManager, Collection } from 'discord.js';
3 |
4 | export async function getNeededRegistryParameters(guildIds: Set = new Set()) {
5 | const { client } = container;
6 |
7 | const applicationCommands = client.application!.commands;
8 | const globalCommands = await applicationCommands.fetch({ withLocalizations: true });
9 | const guildCommands = await fetchGuildCommands(applicationCommands, guildIds);
10 |
11 | return {
12 | applicationCommands,
13 | globalCommands,
14 | guildCommands
15 | };
16 | }
17 |
18 | async function fetchGuildCommands(commands: ApplicationCommandManager, guildIds: Set) {
19 | const map = new Map>();
20 |
21 | for (const guildId of guildIds) {
22 | try {
23 | const guildCommands = await commands.fetch({ guildId, withLocalizations: true });
24 | map.set(guildId, guildCommands);
25 | } catch (err) {
26 | const { preventFailedToFetchLogForGuilds } = container.client.options;
27 |
28 | if (preventFailedToFetchLogForGuilds === true) continue;
29 |
30 | if (Array.isArray(preventFailedToFetchLogForGuilds) && !preventFailedToFetchLogForGuilds?.includes(guildId)) {
31 | const guild = container.client.guilds.resolve(guildId) ?? { name: 'Guild not in cache' };
32 | container.logger.warn(
33 | `ApplicationCommandRegistries: Failed to fetch guild commands for guild "${guild.name}" (${guildId}).`,
34 | 'Make sure to authorize your application with the "applications.commands" scope in that guild.'
35 | );
36 | }
37 | }
38 | }
39 |
40 | return map;
41 | }
42 |
--------------------------------------------------------------------------------
/src/lib/utils/application-commands/registriesErrors.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import type { Command } from '../../structures/Command';
3 | import { Events } from '../../types/Events';
4 | import { bulkOverwriteError } from './registriesLog';
5 |
6 | /**
7 | * Opinionatedly logs the encountered registry error.
8 | * @param error The emitted error
9 | * @param command The command which had the error
10 | */
11 | export function emitPerRegistryError(error: unknown, command: Command) {
12 | const { name, location } = command;
13 | const { client, logger } = container;
14 |
15 | if (client.listenerCount(Events.CommandApplicationCommandRegistryError)) {
16 | client.emit(Events.CommandApplicationCommandRegistryError, error, command);
17 | } else {
18 | logger.error(
19 | `Encountered error while handling the command application command registry for command "${name}" at path "${location.full}"`,
20 | error
21 | );
22 | }
23 | }
24 |
25 | /**
26 | * Opinionatedly logs any bulk overwrite registries error.
27 | * @param error The emitted error
28 | * @param guildId The guild id in which the error was caused
29 | */
30 | export function emitBulkOverwriteError(error: unknown, guildId: string | null) {
31 | const { client } = container;
32 |
33 | if (client.listenerCount(Events.ApplicationCommandRegistriesBulkOverwriteError)) {
34 | client.emit(Events.ApplicationCommandRegistriesBulkOverwriteError, error, guildId);
35 | } else if (guildId) {
36 | bulkOverwriteError(`Failed to overwrite guild application commands for guild ${guildId}`, error);
37 | } else {
38 | bulkOverwriteError(`Failed to overwrite global application commands`, error);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/lib/utils/application-commands/registriesLog.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 |
3 | export function bulkOverwriteInfo(message: string, ...other: unknown[]) {
4 | container.logger.info(`ApplicationCommandRegistries(BulkOverwrite) ${message}`, ...other);
5 | }
6 |
7 | export function bulkOverwriteError(message: string, ...other: unknown[]) {
8 | container.logger.error(`ApplicationCommandRegistries(BulkOverwrite) ${message}`, ...other);
9 | }
10 |
11 | export function bulkOverwriteWarn(message: string, ...other: unknown[]) {
12 | container.logger.warn(`ApplicationCommandRegistries(BulkOverwrite) ${message}`, ...other);
13 | }
14 |
15 | export function bulkOverwriteDebug(message: string, ...other: unknown[]) {
16 | container.logger.debug(`ApplicationCommandRegistries(BulkOverwrite) ${message}`, ...other);
17 | }
18 |
--------------------------------------------------------------------------------
/src/lib/utils/logger/ILogger.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * The logger levels for the {@link ILogger}.
3 | */
4 | export enum LogLevel {
5 | /**
6 | * The lowest log level, used when calling {@link ILogger.trace}.
7 | */
8 | Trace = 10,
9 |
10 | /**
11 | * The debug level, used when calling {@link ILogger.debug}.
12 | */
13 | Debug = 20,
14 |
15 | /**
16 | * The info level, used when calling {@link ILogger.info}.
17 | */
18 | Info = 30,
19 |
20 | /**
21 | * The warning level, used when calling {@link ILogger.warn}.
22 | */
23 | Warn = 40,
24 |
25 | /**
26 | * The error level, used when calling {@link ILogger.error}.
27 | */
28 | Error = 50,
29 |
30 | /**
31 | * The critical level, used when calling {@link ILogger.fatal}.
32 | */
33 | Fatal = 60,
34 |
35 | /**
36 | * An unknown or uncategorized level.
37 | */
38 | None = 100
39 | }
40 |
41 | export interface ILogger {
42 | /**
43 | * Checks whether a level is supported.
44 | * @param level The level to check.
45 | */
46 | has(level: LogLevel): boolean;
47 |
48 | /**
49 | * Alias of {@link ILogger.write} with {@link LogLevel.Trace} as level.
50 | * @param values The values to log.
51 | */
52 | trace(...values: readonly unknown[]): void;
53 |
54 | /**
55 | * Alias of {@link ILogger.write} with {@link LogLevel.Debug} as level.
56 | * @param values The values to log.
57 | */
58 | debug(...values: readonly unknown[]): void;
59 |
60 | /**
61 | * Alias of {@link ILogger.write} with {@link LogLevel.Info} as level.
62 | * @param values The values to log.
63 | */
64 | info(...values: readonly unknown[]): void;
65 |
66 | /**
67 | * Alias of {@link ILogger.write} with {@link LogLevel.Warn} as level.
68 | * @param values The values to log.
69 | */
70 | warn(...values: readonly unknown[]): void;
71 |
72 | /**
73 | * Alias of {@link ILogger.write} with {@link LogLevel.Error} as level.
74 | * @param values The values to log.
75 | */
76 | error(...values: readonly unknown[]): void;
77 |
78 | /**
79 | * Alias of {@link ILogger.write} with {@link LogLevel.Fatal} as level.
80 | * @param values The values to log.
81 | */
82 | fatal(...values: readonly unknown[]): void;
83 |
84 | /**
85 | * Writes the log message given a level and the value(s).
86 | * @param level The log level.
87 | * @param values The values to log.
88 | */
89 | write(level: LogLevel, ...values: readonly unknown[]): void;
90 | }
91 |
--------------------------------------------------------------------------------
/src/lib/utils/logger/Logger.ts:
--------------------------------------------------------------------------------
1 | import { LogLevel, type ILogger } from './ILogger';
2 |
3 | export class Logger implements ILogger {
4 | public level: LogLevel;
5 |
6 | public constructor(level: LogLevel) {
7 | this.level = level;
8 | }
9 |
10 | public has(level: LogLevel): boolean {
11 | return level >= this.level;
12 | }
13 |
14 | public trace(...values: readonly unknown[]): void {
15 | this.write(LogLevel.Trace, ...values);
16 | }
17 |
18 | public debug(...values: readonly unknown[]): void {
19 | this.write(LogLevel.Debug, ...values);
20 | }
21 |
22 | public info(...values: readonly unknown[]): void {
23 | this.write(LogLevel.Info, ...values);
24 | }
25 |
26 | public warn(...values: readonly unknown[]): void {
27 | this.write(LogLevel.Warn, ...values);
28 | }
29 |
30 | public error(...values: readonly unknown[]): void {
31 | this.write(LogLevel.Error, ...values);
32 | }
33 |
34 | public fatal(...values: readonly unknown[]): void {
35 | this.write(LogLevel.Fatal, ...values);
36 | }
37 |
38 | public write(level: LogLevel, ...values: readonly unknown[]): void {
39 | if (!this.has(level)) return;
40 | const method = Logger.levels.get(level);
41 | if (typeof method === 'string') console[method](`[${method.toUpperCase()}]`, ...values);
42 | }
43 |
44 | protected static readonly levels = new Map([
45 | [LogLevel.Trace, 'trace'],
46 | [LogLevel.Debug, 'debug'],
47 | [LogLevel.Info, 'info'],
48 | [LogLevel.Warn, 'warn'],
49 | [LogLevel.Error, 'error'],
50 | [LogLevel.Fatal, 'error']
51 | ]);
52 | }
53 |
54 | export type LogMethods = 'trace' | 'debug' | 'info' | 'warn' | 'error';
55 |
--------------------------------------------------------------------------------
/src/lib/utils/preconditions/IPreconditionContainer.ts:
--------------------------------------------------------------------------------
1 | import type { Result } from '@sapphire/result';
2 | import type { Awaitable } from '@sapphire/utilities';
3 | import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js';
4 | import type { UserError } from '../../errors/UserError';
5 | import type { Command } from '../../structures/Command';
6 | import type { PreconditionContext } from '../../structures/Precondition';
7 |
8 | /**
9 | * Defines the result's value for a PreconditionContainer.
10 | * @since 1.0.0
11 | */
12 | export type PreconditionContainerResult = Result;
13 |
14 | /**
15 | * Defines the return type of the generic {@link IPreconditionContainer.messageRun}.
16 | * @since 1.0.0
17 | */
18 | export type PreconditionContainerReturn = Awaitable;
19 |
20 | /**
21 | * Async-only version of {@link PreconditionContainerReturn}, to be used when the run method is async.
22 | * @since 1.0.0
23 | */
24 | export type AsyncPreconditionContainerReturn = Promise;
25 |
26 | /**
27 | * An abstracted precondition container to be implemented by classes.
28 | * @since 1.0.0
29 | */
30 | export interface IPreconditionContainer {
31 | /**
32 | * Runs a precondition container.
33 | * @since 1.0.0
34 | * @param message The message that ran this precondition.
35 | * @param command The command the message invoked.
36 | * @param context The context for the precondition.
37 | */
38 | messageRun(message: Message, command: Command, context?: PreconditionContext): PreconditionContainerReturn;
39 | /**
40 | * Runs a precondition container.
41 | * @since 3.0.0
42 | * @param interaction The interaction that ran this precondition.
43 | * @param command The command the interaction invoked.
44 | * @param context The context for the precondition.
45 | */
46 | chatInputRun(interaction: ChatInputCommandInteraction, command: Command, context?: PreconditionContext): PreconditionContainerReturn;
47 | /**
48 | * Runs a precondition container.
49 | * @since 3.0.0
50 | * @param interaction The interaction that ran this precondition.
51 | * @param command The command the interaction invoked.
52 | * @param context The context for the precondition.
53 | */
54 | contextMenuRun(interaction: ContextMenuCommandInteraction, command: Command, context?: PreconditionContext): PreconditionContainerReturn;
55 | }
56 |
--------------------------------------------------------------------------------
/src/lib/utils/preconditions/conditions/PreconditionConditionAnd.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import type { IPreconditionCondition } from './IPreconditionCondition';
3 |
4 | /**
5 | * An {@link IPreconditionCondition} which runs all containers similarly to doing (V0 && V1 [&& V2 [&& V3 ...]]).
6 | * @since 1.0.0
7 | */
8 | export const PreconditionConditionAnd: IPreconditionCondition = {
9 | async messageSequential(message, command, entries, context) {
10 | for (const child of entries) {
11 | const result = await child.messageRun(message, command, context);
12 | if (result.isErr()) return result;
13 | }
14 |
15 | return Result.ok();
16 | },
17 | async messageParallel(message, command, entries, context) {
18 | const results = await Promise.all(entries.map((entry) => entry.messageRun(message, command, context)));
19 | // This is simplified compared to PreconditionContainerAny, because we're looking for the first error.
20 | // However, the base implementation short-circuits with the first Ok.
21 | return results.find((res) => res.isErr()) ?? Result.ok();
22 | },
23 | async chatInputSequential(interaction, command, entries, context) {
24 | for (const child of entries) {
25 | const result = await child.chatInputRun(interaction, command, context);
26 | if (result.isErr()) return result;
27 | }
28 |
29 | return Result.ok();
30 | },
31 | async chatInputParallel(interaction, command, entries, context) {
32 | const results = await Promise.all(entries.map((entry) => entry.chatInputRun(interaction, command, context)));
33 | // This is simplified compared to PreconditionContainerAny, because we're looking for the first error.
34 | // However, the base implementation short-circuits with the first Ok.
35 | return results.find((res) => res.isErr()) ?? Result.ok();
36 | },
37 | async contextMenuSequential(interaction, command, entries, context) {
38 | for (const child of entries) {
39 | const result = await child.contextMenuRun(interaction, command, context);
40 | if (result.isErr()) return result;
41 | }
42 |
43 | return Result.ok();
44 | },
45 | async contextMenuParallel(interaction, command, entries, context) {
46 | const results = await Promise.all(entries.map((entry) => entry.contextMenuRun(interaction, command, context)));
47 | // This is simplified compared to PreconditionContainerAny, because we're looking for the first error.
48 | // However, the base implementation short-circuits with the first Ok.
49 | return results.find((res) => res.isErr()) ?? Result.ok();
50 | }
51 | };
52 |
--------------------------------------------------------------------------------
/src/lib/utils/preconditions/conditions/PreconditionConditionOr.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import type { PreconditionContainerResult } from '../IPreconditionContainer';
3 | import type { IPreconditionCondition } from './IPreconditionCondition';
4 |
5 | /**
6 | * An {@link IPreconditionCondition} which runs all containers similarly to doing (V0 || V1 [|| V2 [|| V3 ...]]).
7 | * @since 1.0.0
8 | */
9 | export const PreconditionConditionOr: IPreconditionCondition = {
10 | async messageSequential(message, command, entries, context) {
11 | let error: PreconditionContainerResult | null = null;
12 | for (const child of entries) {
13 | const result = await child.messageRun(message, command, context);
14 | if (result.isOk()) return result;
15 | error = result;
16 | }
17 |
18 | return error ?? Result.ok();
19 | },
20 | async messageParallel(message, command, entries, context) {
21 | const results = await Promise.all(entries.map((entry) => entry.messageRun(message, command, context)));
22 |
23 | let error: PreconditionContainerResult | null = null;
24 | for (const result of results) {
25 | if (result.isOk()) return result;
26 | error = result;
27 | }
28 |
29 | return error ?? Result.ok();
30 | },
31 | async chatInputSequential(interaction, command, entries, context) {
32 | let error: PreconditionContainerResult | null = null;
33 | for (const child of entries) {
34 | const result = await child.chatInputRun(interaction, command, context);
35 | if (result.isOk()) return result;
36 | error = result;
37 | }
38 |
39 | return error ?? Result.ok();
40 | },
41 | async chatInputParallel(interaction, command, entries, context) {
42 | const results = await Promise.all(entries.map((entry) => entry.chatInputRun(interaction, command, context)));
43 |
44 | let error: PreconditionContainerResult | null = null;
45 | for (const result of results) {
46 | if (result.isOk()) return result;
47 | error = result;
48 | }
49 |
50 | return error ?? Result.ok();
51 | },
52 | async contextMenuSequential(interaction, command, entries, context) {
53 | let error: PreconditionContainerResult | null = null;
54 | for (const child of entries) {
55 | const result = await child.contextMenuRun(interaction, command, context);
56 | if (result.isOk()) return result;
57 | error = result;
58 | }
59 |
60 | return error ?? Result.ok();
61 | },
62 | async contextMenuParallel(interaction, command, entries, context) {
63 | const results = await Promise.all(entries.map((entry) => entry.contextMenuRun(interaction, command, context)));
64 |
65 | let error: PreconditionContainerResult | null = null;
66 | for (const result of results) {
67 | if (result.isOk()) return result;
68 | error = result;
69 | }
70 |
71 | return error ?? Result.ok();
72 | }
73 | };
74 |
--------------------------------------------------------------------------------
/src/lib/utils/preconditions/containers/ClientPermissionsPrecondition.ts:
--------------------------------------------------------------------------------
1 | import { PermissionsBitField, type PermissionResolvable } from 'discord.js';
2 | import type { PreconditionSingleResolvableDetails } from '../PreconditionContainerSingle';
3 |
4 | /**
5 | * Constructs a contextful permissions precondition requirement.
6 | * @since 1.0.0
7 | * @example
8 | * ```typescript
9 | * export class CoreCommand extends Command {
10 | * public constructor(context: Command.Context) {
11 | * super(context, {
12 | * preconditions: [
13 | * 'GuildOnly',
14 | * new ClientPermissionsPrecondition('ADD_REACTIONS')
15 | * ]
16 | * });
17 | * }
18 | *
19 | * public messageRun(message: Message, args: Args) {
20 | * // ...
21 | * }
22 | * }
23 | * ```
24 | */
25 | export class ClientPermissionsPrecondition implements PreconditionSingleResolvableDetails<'ClientPermissions'> {
26 | public name: 'ClientPermissions';
27 | public context: { permissions: PermissionsBitField };
28 |
29 | /**
30 | * Constructs a precondition container entry.
31 | * @param permissions The permissions that will be required by this command.
32 | */
33 | public constructor(permissions: PermissionResolvable) {
34 | this.name = 'ClientPermissions';
35 | this.context = {
36 | permissions: new PermissionsBitField(permissions)
37 | };
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/lib/utils/preconditions/containers/UserPermissionsPrecondition.ts:
--------------------------------------------------------------------------------
1 | import { PermissionsBitField, type PermissionResolvable } from 'discord.js';
2 | import type { PreconditionSingleResolvableDetails } from '../PreconditionContainerSingle';
3 |
4 | /**
5 | * Constructs a contextful permissions precondition requirement.
6 | * @since 1.0.0
7 | * @example
8 | * ```typescript
9 | * export class CoreCommand extends Command {
10 | * public constructor(context: Command.Context) {
11 | * super(context, {
12 | * preconditions: [
13 | * 'GuildOnly',
14 | * new UserPermissionsPrecondition('ADD_REACTIONS')
15 | * ]
16 | * });
17 | * }
18 | *
19 | * public messageRun(message: Message, args: Args) {
20 | * // ...
21 | * }
22 | * }
23 | * ```
24 | */
25 | export class UserPermissionsPrecondition implements PreconditionSingleResolvableDetails<'UserPermissions'> {
26 | public name: 'UserPermissions';
27 | public context: { permissions: PermissionsBitField };
28 |
29 | /**
30 | * Constructs a precondition container entry.
31 | * @param permissions The permissions that will be required by this command.
32 | */
33 | public constructor(permissions: PermissionResolvable) {
34 | this.name = 'UserPermissions';
35 | this.context = {
36 | permissions: new PermissionsBitField(permissions)
37 | };
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/lib/utils/resolvers/resolveGuildChannelPredicate.ts:
--------------------------------------------------------------------------------
1 | import type { ChannelTypes, GuildBasedChannelTypes } from '@sapphire/discord.js-utilities';
2 | import { Result } from '@sapphire/result';
3 | import type { Nullish } from '@sapphire/utilities';
4 | import type { Guild } from 'discord.js';
5 | import type { Identifiers } from '../../errors/Identifiers';
6 | import { resolveGuildChannel } from '../../resolvers/guildChannel';
7 |
8 | export function resolveGuildChannelPredicate(
9 | parameter: string,
10 | guild: Guild,
11 | predicate: (channel: ChannelTypes | Nullish) => channel is TChannel,
12 | error: TError
13 | ): Result {
14 | const result = resolveGuildChannel(parameter, guild);
15 | return result.mapInto((channel) => (predicate(channel) ? Result.ok(channel) : Result.err(error)));
16 | }
17 |
--------------------------------------------------------------------------------
/src/lib/utils/strategies/FlagUnorderedStrategy.ts:
--------------------------------------------------------------------------------
1 | import { PrefixedStrategy } from '@sapphire/lexure';
2 | import { Option } from '@sapphire/result';
3 |
4 | /**
5 | * The strategy options used in Sapphire.
6 | */
7 | export interface FlagStrategyOptions {
8 | /**
9 | * The accepted flags. Flags are key-only identifiers that can be placed anywhere in the command. Two different types are accepted:
10 | * * An array of strings, e.g. [`silent`].
11 | * * A boolean defining whether the strategy should accept all keys (`true`) or none at all (`false`).
12 | * @default []
13 | */
14 | flags?: readonly string[] | boolean;
15 |
16 | /**
17 | * The accepted options. Options are key-value identifiers that can be placed anywhere in the command. Two different types are accepted:
18 | * * An array of strings, e.g. [`silent`].
19 | * * A boolean defining whether the strategy should accept all keys (`true`) or none at all (`false`).
20 | * @default []
21 | */
22 | options?: readonly string[] | boolean;
23 |
24 | /**
25 | * The prefixes for both flags and options.
26 | * @default ['--', '-', '—']
27 | */
28 | prefixes?: string[];
29 |
30 | /**
31 | * The flag separators.
32 | * @default ['=', ':']
33 | */
34 | separators?: string[];
35 | }
36 |
37 | const never = () => Option.none;
38 | const always = () => true;
39 |
40 | export class FlagUnorderedStrategy extends PrefixedStrategy {
41 | public readonly flags: readonly string[] | true;
42 | public readonly options: readonly string[] | true;
43 |
44 | public constructor({ flags, options, prefixes = ['--', '-', '—'], separators = ['=', ':'] }: FlagStrategyOptions = {}) {
45 | super(prefixes, separators);
46 | this.flags = flags || [];
47 | this.options = options || [];
48 |
49 | if (this.flags === true) this.allowedFlag = always;
50 | else if (this.flags.length === 0) this.matchFlag = never;
51 |
52 | if (this.options === true) {
53 | this.allowedOption = always;
54 | } else if (this.options.length === 0) {
55 | this.matchOption = never;
56 | }
57 | }
58 |
59 | public override matchFlag(s: string): Option {
60 | const result = super.matchFlag(s);
61 |
62 | // The flag must be an allowed one.
63 | if (result.isSomeAnd((value) => this.allowedFlag(value))) return result;
64 |
65 | // If it did not match a flag, return null.
66 | return Option.none;
67 | }
68 |
69 | public override matchOption(s: string): Option {
70 | const result = super.matchOption(s);
71 |
72 | if (result.isSomeAnd((option) => this.allowedOption(option[0]))) return result;
73 |
74 | return Option.none;
75 | }
76 |
77 | private allowedFlag(s: string) {
78 | return (this.flags as readonly string[]).includes(s);
79 | }
80 |
81 | private allowedOption(s: string) {
82 | return (this.options as readonly string[]).includes(s);
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/listeners/CoreInteractionCreate.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import type { Interaction } from 'discord.js';
3 | import { Listener } from '../lib/structures/Listener';
4 | import { Events } from '../lib/types/Events';
5 |
6 | export class CoreListener extends Listener {
7 | public constructor(context: Listener.LoaderContext) {
8 | super(context, { event: Events.InteractionCreate });
9 | }
10 |
11 | public async run(interaction: Interaction) {
12 | if (interaction.isChatInputCommand()) {
13 | this.container.client.emit(Events.PossibleChatInputCommand, interaction);
14 | } else if (interaction.isContextMenuCommand()) {
15 | this.container.client.emit(Events.PossibleContextMenuCommand, interaction);
16 | } else if (interaction.isAutocomplete()) {
17 | this.container.client.emit(Events.PossibleAutocompleteInteraction, interaction);
18 | } else if (interaction.isMessageComponent() || interaction.isModalSubmit()) {
19 | await this.container.stores.get('interaction-handlers').run(interaction);
20 | } else {
21 | this.container.logger.warn(`[Sapphire ${this.location.name}] Unhandled interaction type: ${(interaction as any).constructor.name}`);
22 | }
23 | }
24 | }
25 |
26 | void container.stores.loadPiece({
27 | name: 'CoreInteractionCreate',
28 | piece: CoreListener,
29 | store: 'listeners'
30 | });
31 |
--------------------------------------------------------------------------------
/src/listeners/CoreReady.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import { Listener } from '../lib/structures/Listener';
3 | import { Events } from '../lib/types/Events';
4 | import { handleRegistryAPICalls } from '../lib/utils/application-commands/ApplicationCommandRegistries';
5 |
6 | export class CoreListener extends Listener {
7 | public constructor(context: Listener.LoaderContext) {
8 | super(context, { event: Events.ClientReady, once: true });
9 | }
10 |
11 | public async run() {
12 | this.container.client.id ??= this.container.client.user?.id ?? null;
13 |
14 | await handleRegistryAPICalls();
15 | }
16 | }
17 |
18 | void container.stores.loadPiece({
19 | name: 'CoreReady',
20 | piece: CoreListener,
21 | store: 'listeners'
22 | });
23 |
--------------------------------------------------------------------------------
/src/listeners/_load.ts:
--------------------------------------------------------------------------------
1 | import './CoreInteractionCreate';
2 | import './CoreReady';
3 | import './application-commands/CorePossibleAutocompleteInteraction';
4 | import './application-commands/chat-input/CoreChatInputCommandAccepted';
5 | import './application-commands/chat-input/CorePossibleChatInputCommand';
6 | import './application-commands/chat-input/CorePreChatInputCommandRun';
7 | import './application-commands/context-menu/CoreContextMenuCommandAccepted';
8 | import './application-commands/context-menu/CorePossibleContextMenuCommand';
9 | import './application-commands/context-menu/CorePreContextMenuCommandRun';
10 |
--------------------------------------------------------------------------------
/src/listeners/application-commands/CorePossibleAutocompleteInteraction.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import type { AutocompleteInteraction } from 'discord.js';
3 | import { Listener } from '../../lib/structures/Listener';
4 | import type { AutocompleteCommand } from '../../lib/types/CommandTypes';
5 | import { Events } from '../../lib/types/Events';
6 |
7 | export class CoreListener extends Listener {
8 | public constructor(context: Listener.LoaderContext) {
9 | super(context, { event: Events.PossibleAutocompleteInteraction });
10 | }
11 |
12 | public async run(interaction: AutocompleteInteraction) {
13 | const { stores } = this.container;
14 |
15 | const commandStore = stores.get('commands');
16 |
17 | // Try resolving in command
18 | const command = commandStore.get(interaction.commandId) ?? commandStore.get(interaction.commandName);
19 |
20 | if (command?.autocompleteRun) {
21 | try {
22 | await command.autocompleteRun(interaction);
23 | this.container.client.emit(Events.CommandAutocompleteInteractionSuccess, {
24 | command: command as AutocompleteCommand,
25 | context: { commandId: interaction.commandId, commandName: interaction.commandName },
26 | interaction
27 | });
28 | } catch (err) {
29 | this.container.client.emit(Events.CommandAutocompleteInteractionError, err, {
30 | command: command as AutocompleteCommand,
31 | context: { commandId: interaction.commandId, commandName: interaction.commandName },
32 | interaction
33 | });
34 | }
35 | return;
36 | }
37 |
38 | // Unless we ran a command handler, always call interaction handlers with the interaction
39 | await this.container.stores.get('interaction-handlers').run(interaction);
40 | }
41 | }
42 |
43 | void container.stores.loadPiece({
44 | name: 'CorePossibleAutocompleteInteraction',
45 | piece: CoreListener,
46 | store: 'listeners'
47 | });
48 |
--------------------------------------------------------------------------------
/src/listeners/application-commands/chat-input/CoreChatInputCommandAccepted.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import { Result } from '@sapphire/result';
3 | import { Stopwatch } from '@sapphire/stopwatch';
4 | import { Listener } from '../../../lib/structures/Listener';
5 | import { Events, type ChatInputCommandAcceptedPayload } from '../../../lib/types/Events';
6 |
7 | export class CoreListener extends Listener {
8 | public constructor(context: Listener.LoaderContext) {
9 | super(context, { event: Events.ChatInputCommandAccepted });
10 | }
11 |
12 | public async run(payload: ChatInputCommandAcceptedPayload) {
13 | const { command, context, interaction } = payload;
14 |
15 | const result = await Result.fromAsync(async () => {
16 | this.container.client.emit(Events.ChatInputCommandRun, interaction, command, { ...payload });
17 |
18 | const stopwatch = new Stopwatch();
19 | const result = await command.chatInputRun(interaction, context);
20 | const { duration } = stopwatch.stop();
21 |
22 | this.container.client.emit(Events.ChatInputCommandSuccess, { ...payload, result, duration });
23 |
24 | return duration;
25 | });
26 |
27 | result.inspectErr((error) => this.container.client.emit(Events.ChatInputCommandError, error, { ...payload, duration: -1 }));
28 |
29 | this.container.client.emit(Events.ChatInputCommandFinish, interaction, command, {
30 | ...payload,
31 | success: result.isOk(),
32 | duration: result.unwrapOr(-1)
33 | });
34 | }
35 | }
36 |
37 | void container.stores.loadPiece({
38 | name: 'CoreChatInputCommandAccepted',
39 | piece: CoreListener,
40 | store: 'listeners'
41 | });
42 |
--------------------------------------------------------------------------------
/src/listeners/application-commands/chat-input/CorePossibleChatInputCommand.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import type { ChatInputCommandInteraction } from 'discord.js';
3 | import { Listener } from '../../../lib/structures/Listener';
4 | import type { ChatInputCommand } from '../../../lib/types/CommandTypes';
5 | import { Events } from '../../../lib/types/Events';
6 |
7 | export class CoreListener extends Listener {
8 | public constructor(context: Listener.LoaderContext) {
9 | super(context, { event: Events.PossibleChatInputCommand });
10 | }
11 |
12 | public run(interaction: ChatInputCommandInteraction) {
13 | const { client, stores } = this.container;
14 | const commandStore = stores.get('commands');
15 |
16 | const command = commandStore.get(interaction.commandId) ?? commandStore.get(interaction.commandName);
17 | if (!command) {
18 | client.emit(Events.UnknownChatInputCommand, {
19 | interaction,
20 | context: { commandId: interaction.commandId, commandName: interaction.commandName }
21 | });
22 | return;
23 | }
24 |
25 | if (!command.chatInputRun) {
26 | client.emit(Events.CommandDoesNotHaveChatInputCommandHandler, {
27 | command,
28 | interaction,
29 | context: { commandId: interaction.commandId, commandName: interaction.commandName }
30 | });
31 | return;
32 | }
33 |
34 | client.emit(Events.PreChatInputCommandRun, {
35 | command: command as ChatInputCommand,
36 | context: { commandId: interaction.commandId, commandName: interaction.commandName },
37 | interaction
38 | });
39 | }
40 | }
41 |
42 | void container.stores.loadPiece({
43 | name: 'CorePossibleChatInputCommand',
44 | piece: CoreListener,
45 | store: 'listeners'
46 | });
47 |
--------------------------------------------------------------------------------
/src/listeners/application-commands/chat-input/CorePreChatInputCommandRun.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import { Listener } from '../../../lib/structures/Listener';
3 | import { Events, type PreChatInputCommandRunPayload } from '../../../lib/types/Events';
4 |
5 | export class CoreListener extends Listener {
6 | public constructor(context: Listener.LoaderContext) {
7 | super(context, { event: Events.PreChatInputCommandRun });
8 | }
9 |
10 | public async run(payload: PreChatInputCommandRunPayload) {
11 | const { command, interaction } = payload;
12 |
13 | // Run global preconditions:
14 | const globalResult = await this.container.stores.get('preconditions').chatInputRun(interaction, command, payload as any);
15 | if (globalResult.isErr()) {
16 | this.container.client.emit(Events.ChatInputCommandDenied, globalResult.unwrapErr(), payload);
17 | return;
18 | }
19 |
20 | // Run command-specific preconditions:
21 | const localResult = await command.preconditions.chatInputRun(interaction, command, payload as any);
22 | if (localResult.isErr()) {
23 | this.container.client.emit(Events.ChatInputCommandDenied, localResult.unwrapErr(), payload);
24 | return;
25 | }
26 |
27 | this.container.client.emit(Events.ChatInputCommandAccepted, payload);
28 | }
29 | }
30 |
31 | void container.stores.loadPiece({
32 | name: 'CorePreChatInputCommandRun',
33 | piece: CoreListener,
34 | store: 'listeners'
35 | });
36 |
--------------------------------------------------------------------------------
/src/listeners/application-commands/context-menu/CoreContextMenuCommandAccepted.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import { Result } from '@sapphire/result';
3 | import { Stopwatch } from '@sapphire/stopwatch';
4 | import { Listener } from '../../../lib/structures/Listener';
5 | import { Events, type ContextMenuCommandAcceptedPayload } from '../../../lib/types/Events';
6 |
7 | export class CoreListener extends Listener {
8 | public constructor(context: Listener.LoaderContext) {
9 | super(context, { event: Events.ContextMenuCommandAccepted });
10 | }
11 |
12 | public async run(payload: ContextMenuCommandAcceptedPayload) {
13 | const { command, context, interaction } = payload;
14 |
15 | const result = await Result.fromAsync(async () => {
16 | this.container.client.emit(Events.ContextMenuCommandRun, interaction, command, { ...payload });
17 |
18 | const stopwatch = new Stopwatch();
19 | const result = await command.contextMenuRun(interaction, context);
20 | const { duration } = stopwatch.stop();
21 |
22 | this.container.client.emit(Events.ContextMenuCommandSuccess, { ...payload, result, duration });
23 |
24 | return duration;
25 | });
26 |
27 | result.inspectErr((error) => this.container.client.emit(Events.ContextMenuCommandError, error, { ...payload, duration: -1 }));
28 |
29 | this.container.client.emit(Events.ContextMenuCommandFinish, interaction, command, {
30 | ...payload,
31 | success: result.isOk(),
32 | duration: result.unwrapOr(-1)
33 | });
34 | }
35 | }
36 |
37 | void container.stores.loadPiece({
38 | name: 'CoreContextMenuCommandAccepted',
39 | piece: CoreListener,
40 | store: 'listeners'
41 | });
42 |
--------------------------------------------------------------------------------
/src/listeners/application-commands/context-menu/CorePossibleContextMenuCommand.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import type { ContextMenuCommandInteraction } from 'discord.js';
3 | import { Listener } from '../../../lib/structures/Listener';
4 | import type { ContextMenuCommand } from '../../../lib/types/CommandTypes';
5 | import { Events } from '../../../lib/types/Events';
6 |
7 | export class CoreListener extends Listener {
8 | public constructor(context: Listener.LoaderContext) {
9 | super(context, { event: Events.PossibleContextMenuCommand });
10 | }
11 |
12 | public run(interaction: ContextMenuCommandInteraction) {
13 | const { client, stores } = this.container;
14 | const commandStore = stores.get('commands');
15 |
16 | const command = commandStore.get(interaction.commandId) ?? commandStore.get(interaction.commandName);
17 | if (!command) {
18 | client.emit(Events.UnknownContextMenuCommand, {
19 | interaction,
20 | context: { commandId: interaction.commandId, commandName: interaction.commandName }
21 | });
22 | return;
23 | }
24 |
25 | if (!command.contextMenuRun) {
26 | client.emit(Events.CommandDoesNotHaveContextMenuCommandHandler, {
27 | command,
28 | interaction,
29 | context: { commandId: interaction.commandId, commandName: interaction.commandName }
30 | });
31 | return;
32 | }
33 |
34 | client.emit(Events.PreContextMenuCommandRun, {
35 | command: command as ContextMenuCommand,
36 | context: { commandId: interaction.commandId, commandName: interaction.commandName },
37 | interaction
38 | });
39 | }
40 | }
41 |
42 | void container.stores.loadPiece({
43 | name: 'CorePossibleContextMenuCommand',
44 | piece: CoreListener,
45 | store: 'listeners'
46 | });
47 |
--------------------------------------------------------------------------------
/src/listeners/application-commands/context-menu/CorePreContextMenuCommandRun.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import { Listener } from '../../../lib/structures/Listener';
3 | import { Events, type PreContextMenuCommandRunPayload } from '../../../lib/types/Events';
4 |
5 | export class CoreListener extends Listener {
6 | public constructor(context: Listener.LoaderContext) {
7 | super(context, { event: Events.PreContextMenuCommandRun });
8 | }
9 |
10 | public async run(payload: PreContextMenuCommandRunPayload) {
11 | const { command, interaction } = payload;
12 |
13 | // Run global preconditions:
14 | const globalResult = await this.container.stores.get('preconditions').contextMenuRun(interaction, command, payload as any);
15 | if (globalResult.isErr()) {
16 | this.container.client.emit(Events.ContextMenuCommandDenied, globalResult.unwrapErr(), payload);
17 | return;
18 | }
19 |
20 | // Run command-specific preconditions:
21 | const localResult = await command.preconditions.contextMenuRun(interaction, command, payload as any);
22 | if (localResult.isErr()) {
23 | this.container.client.emit(Events.ContextMenuCommandDenied, localResult.unwrapErr(), payload);
24 | return;
25 | }
26 |
27 | this.container.client.emit(Events.ContextMenuCommandAccepted, payload);
28 | }
29 | }
30 |
31 | void container.stores.loadPiece({
32 | name: 'CorePreContextMenuCommandRun',
33 | piece: CoreListener,
34 | store: 'listeners'
35 | });
36 |
--------------------------------------------------------------------------------
/src/optional-listeners/application-command-registries-listeners/CoreApplicationCommandRegistriesInitialising.ts:
--------------------------------------------------------------------------------
1 | import { Listener } from '../../lib/structures/Listener';
2 | import { Events } from '../../lib/types/Events';
3 |
4 | export class CoreListener extends Listener {
5 | public constructor(context: Listener.LoaderContext) {
6 | super(context, { event: Events.ApplicationCommandRegistriesInitialising, once: true });
7 | }
8 |
9 | public run() {
10 | this.container.logger.info('ApplicationCommandRegistries: Initializing...');
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/optional-listeners/application-command-registries-listeners/CoreApplicationCommandRegistriesRegistered.ts:
--------------------------------------------------------------------------------
1 | import { Listener } from '../../lib/structures/Listener';
2 | import { Events } from '../../lib/types/Events';
3 | import type { ApplicationCommandRegistry } from '../../lib/utils/application-commands/ApplicationCommandRegistry';
4 |
5 | export class CoreListener extends Listener {
6 | public constructor(context: Listener.LoaderContext) {
7 | super(context, { event: Events.ApplicationCommandRegistriesRegistered, once: true });
8 | }
9 |
10 | public run(_registries: Map, timeTaken: number) {
11 | this.container.logger.info(`ApplicationCommandRegistries: Took ${timeTaken.toLocaleString()}ms to initialize.`);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/optional-listeners/application-command-registries-listeners/_load.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import { CoreListener as CoreApplicationCommandRegistriesInitialising } from './CoreApplicationCommandRegistriesInitialising';
3 | import { CoreListener as CoreApplicationCommandRegistriesRegistered } from './CoreApplicationCommandRegistriesRegistered';
4 |
5 | export function loadApplicationCommandRegistriesListeners() {
6 | const store = 'listeners' as const;
7 | void container.stores.loadPiece({
8 | name: 'CoreApplicationCommandRegistriesInitialising',
9 | piece: CoreApplicationCommandRegistriesInitialising,
10 | store
11 | });
12 | void container.stores.loadPiece({
13 | name: 'CoreApplicationCommandRegistriesRegistered',
14 | piece: CoreApplicationCommandRegistriesRegistered,
15 | store
16 | });
17 | }
18 |
--------------------------------------------------------------------------------
/src/optional-listeners/error-listeners/CoreChatInputCommandError.ts:
--------------------------------------------------------------------------------
1 | import { Listener } from '../../lib/structures/Listener';
2 | import { Events, type ChatInputCommandErrorPayload } from '../../lib/types/Events';
3 |
4 | export class CoreListener extends Listener {
5 | public constructor(context: Listener.LoaderContext) {
6 | super(context, { event: Events.ChatInputCommandError });
7 | }
8 |
9 | public run(error: unknown, context: ChatInputCommandErrorPayload) {
10 | const { name, location } = context.command;
11 | this.container.logger.error(`Encountered error on chat input command "${name}" at path "${location.full}"`, error);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/optional-listeners/error-listeners/CoreCommandApplicationCommandRegistryError.ts:
--------------------------------------------------------------------------------
1 | import type { Command } from '../../lib/structures/Command';
2 | import { Listener } from '../../lib/structures/Listener';
3 | import { Events } from '../../lib/types/Events';
4 |
5 | export class CoreListener extends Listener {
6 | public constructor(context: Listener.LoaderContext) {
7 | super(context, { event: Events.CommandApplicationCommandRegistryError });
8 | }
9 |
10 | public run(error: unknown, command: Command) {
11 | const { name, location } = command;
12 | this.container.logger.error(
13 | `Encountered error while handling the command application command registry for command "${name}" at path "${location.full}"`,
14 | error
15 | );
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/optional-listeners/error-listeners/CoreCommandAutocompleteInteractionError.ts:
--------------------------------------------------------------------------------
1 | import { Listener } from '../../lib/structures/Listener';
2 | import { Events, type AutocompleteInteractionPayload } from '../../lib/types/Events';
3 |
4 | export class CoreListener extends Listener {
5 | public constructor(context: Listener.LoaderContext) {
6 | super(context, { event: Events.CommandAutocompleteInteractionError });
7 | }
8 |
9 | public run(error: unknown, context: AutocompleteInteractionPayload) {
10 | const { name, location } = context.command;
11 | this.container.logger.error(
12 | `Encountered error while handling an autocomplete run method on command "${name}" at path "${location.full}"`,
13 | error
14 | );
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/optional-listeners/error-listeners/CoreContextMenuCommandError.ts:
--------------------------------------------------------------------------------
1 | import { Listener } from '../../lib/structures/Listener';
2 | import { Events, type ContextMenuCommandErrorPayload } from '../../lib/types/Events';
3 |
4 | export class CoreListener extends Listener {
5 | public constructor(context: Listener.LoaderContext) {
6 | super(context, { event: Events.ContextMenuCommandError });
7 | }
8 |
9 | public run(error: unknown, context: ContextMenuCommandErrorPayload) {
10 | const { name, location } = context.command;
11 | this.container.logger.error(`Encountered error on message command "${name}" at path "${location.full}"`, error);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/optional-listeners/error-listeners/CoreInteractionHandlerError.ts:
--------------------------------------------------------------------------------
1 | import { Listener } from '../../lib/structures/Listener';
2 | import { Events, type InteractionHandlerError } from '../../lib/types/Events';
3 |
4 | export class CoreListener extends Listener {
5 | public constructor(context: Listener.LoaderContext) {
6 | super(context, { event: Events.InteractionHandlerError });
7 | }
8 |
9 | public run(error: unknown, context: InteractionHandlerError) {
10 | const { name, location } = context.handler;
11 | this.container.logger.error(
12 | `Encountered error while handling an interaction handler run method for interaction-handler "${name}" at path "${location.full}"`,
13 | error
14 | );
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/optional-listeners/error-listeners/CoreInteractionHandlerParseError.ts:
--------------------------------------------------------------------------------
1 | import { Listener } from '../../lib/structures/Listener';
2 | import { Events, type InteractionHandlerParseError as InteractionHandlerParseErrorPayload } from '../../lib/types/Events';
3 |
4 | export class CoreListener extends Listener {
5 | public constructor(context: Listener.LoaderContext) {
6 | super(context, { event: Events.InteractionHandlerParseError });
7 | }
8 |
9 | public run(error: unknown, context: InteractionHandlerParseErrorPayload) {
10 | const { name, location } = context.handler;
11 | this.container.logger.error(
12 | `Encountered error while handling an interaction handler parse method for interaction-handler "${name}" at path "${location.full}"`,
13 | error
14 | );
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/optional-listeners/error-listeners/CoreListenerError.ts:
--------------------------------------------------------------------------------
1 | import { Listener } from '../../lib/structures/Listener';
2 | import { Events, type ListenerErrorPayload } from '../../lib/types/Events';
3 |
4 | export class CoreListener extends Listener {
5 | public constructor(context: Listener.LoaderContext) {
6 | super(context, { event: Events.ListenerError });
7 | }
8 |
9 | public run(error: unknown, context: ListenerErrorPayload) {
10 | const { name, event, location } = context.piece;
11 | this.container.logger.error(`Encountered error on event listener "${name}" for event "${String(event)}" at path "${location.full}"`, error);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/optional-listeners/error-listeners/CoreMessageCommandError.ts:
--------------------------------------------------------------------------------
1 | import { Listener } from '../../lib/structures/Listener';
2 | import { Events, type MessageCommandErrorPayload } from '../../lib/types/Events';
3 |
4 | export class CoreListener extends Listener {
5 | public constructor(context: Listener.LoaderContext) {
6 | super(context, { event: Events.MessageCommandError });
7 | }
8 |
9 | public run(error: unknown, context: MessageCommandErrorPayload) {
10 | const { name, location } = context.command;
11 | this.container.logger.error(`Encountered error on message command "${name}" at path "${location.full}"`, error);
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/optional-listeners/error-listeners/_load.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import { CoreListener as CoreChatInputCommandError } from './CoreChatInputCommandError';
3 | import { CoreListener as CoreCommandApplicationCommandRegistryError } from './CoreCommandApplicationCommandRegistryError';
4 | import { CoreListener as CoreCommandAutocompleteInteractionError } from './CoreCommandAutocompleteInteractionError';
5 | import { CoreListener as CoreContextMenuCommandError } from './CoreContextMenuCommandError';
6 | import { CoreListener as CoreInteractionHandlerError } from './CoreInteractionHandlerError';
7 | import { CoreListener as CoreInteractionHandlerParseError } from './CoreInteractionHandlerParseError';
8 | import { CoreListener as CoreListenerError } from './CoreListenerError';
9 | import { CoreListener as CoreMessageCommandError } from './CoreMessageCommandError';
10 |
11 | export function loadErrorListeners() {
12 | const store = 'listeners' as const;
13 | void container.stores.loadPiece({ name: 'CoreChatInputCommandError', piece: CoreChatInputCommandError, store });
14 | void container.stores.loadPiece({ name: 'CoreCommandApplicationCommandRegistryError', piece: CoreCommandApplicationCommandRegistryError, store });
15 | void container.stores.loadPiece({ name: 'CoreCommandAutocompleteInteractionError', piece: CoreCommandAutocompleteInteractionError, store });
16 | void container.stores.loadPiece({ name: 'CoreContextMenuCommandError', piece: CoreContextMenuCommandError, store });
17 | void container.stores.loadPiece({ name: 'CoreInteractionHandlerError', piece: CoreInteractionHandlerError, store });
18 | void container.stores.loadPiece({ name: 'CoreInteractionHandlerParseError', piece: CoreInteractionHandlerParseError, store });
19 | void container.stores.loadPiece({ name: 'CoreListenerError', piece: CoreListenerError, store });
20 | void container.stores.loadPiece({ name: 'CoreMessageCommandError', piece: CoreMessageCommandError, store });
21 | }
22 |
--------------------------------------------------------------------------------
/src/optional-listeners/message-command-listeners/CoreMessageCommandAccepted.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import { Stopwatch } from '@sapphire/stopwatch';
3 | import { Listener } from '../../lib/structures/Listener';
4 | import { Events, type MessageCommandAcceptedPayload } from '../../lib/types/Events';
5 |
6 | export class CoreListener extends Listener {
7 | public constructor(context: Listener.LoaderContext) {
8 | super(context, { event: Events.MessageCommandAccepted });
9 | }
10 |
11 | public async run(payload: MessageCommandAcceptedPayload) {
12 | const { message, command, parameters, context } = payload;
13 | const args = await command.messagePreParse(message, parameters, context);
14 |
15 | const result = await Result.fromAsync(async () => {
16 | message.client.emit(Events.MessageCommandRun, message, command, { ...payload, args });
17 |
18 | const stopwatch = new Stopwatch();
19 | const result = await command.messageRun(message, args, context);
20 | const { duration } = stopwatch.stop();
21 |
22 | message.client.emit(Events.MessageCommandSuccess, { ...payload, args, result, duration });
23 |
24 | return duration;
25 | });
26 |
27 | result.inspectErr((error) => message.client.emit(Events.MessageCommandError, error, { ...payload, args, duration: -1 }));
28 |
29 | message.client.emit(Events.MessageCommandFinish, message, command, {
30 | ...payload,
31 | args,
32 | success: result.isOk(),
33 | duration: result.unwrapOr(-1)
34 | });
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/optional-listeners/message-command-listeners/CoreMessageCommandTyping.ts:
--------------------------------------------------------------------------------
1 | import { isStageChannel } from '@sapphire/discord.js-utilities';
2 | import { ChannelType, type Message } from 'discord.js';
3 | import { Listener } from '../../lib/structures/Listener';
4 | import type { MessageCommand } from '../../lib/types/CommandTypes';
5 | import { Events, type MessageCommandRunPayload } from '../../lib/types/Events';
6 |
7 | export class CoreListener extends Listener {
8 | public constructor(context: Listener.LoaderContext) {
9 | super(context, { event: Events.MessageCommandRun });
10 | this.enabled = this.container.client.options.typing ?? false;
11 | }
12 |
13 | public async run(message: Message, command: MessageCommand, payload: MessageCommandRunPayload) {
14 | if (!command.typing || isStageChannel(message.channel)) {
15 | return;
16 | }
17 |
18 | if (message.channel.type === ChannelType.GroupDM) {
19 | return;
20 | }
21 |
22 | try {
23 | await message.channel.sendTyping();
24 | } catch (error) {
25 | message.client.emit(Events.MessageCommandTypingError, error as Error, { ...payload, command, message });
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/optional-listeners/message-command-listeners/CoreMessageCreate.ts:
--------------------------------------------------------------------------------
1 | import type { Message } from 'discord.js';
2 | import { Listener } from '../../lib/structures/Listener';
3 | import { Events } from '../../lib/types/Events';
4 |
5 | export class CoreListener extends Listener {
6 | public constructor(context: Listener.LoaderContext) {
7 | super(context, { event: Events.MessageCreate });
8 | }
9 |
10 | public run(message: Message) {
11 | // Stop bots and webhooks from running commands.
12 | if (message.author.bot || message.webhookId) return;
13 |
14 | // Run the message parser.
15 | this.container.client.emit(Events.PreMessageParsed, message);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/optional-listeners/message-command-listeners/CorePreMessageCommandRun.ts:
--------------------------------------------------------------------------------
1 | import { Listener } from '../../lib/structures/Listener';
2 | import { Events, type PreMessageCommandRunPayload } from '../../lib/types/Events';
3 |
4 | export class CoreListener extends Listener {
5 | public constructor(context: Listener.LoaderContext) {
6 | super(context, { event: Events.PreMessageCommandRun });
7 | }
8 |
9 | public async run(payload: PreMessageCommandRunPayload) {
10 | const { message, command } = payload;
11 |
12 | // Run global preconditions:
13 | const globalResult = await this.container.stores.get('preconditions').messageRun(message, command, payload as any);
14 | if (globalResult.isErr()) {
15 | message.client.emit(Events.MessageCommandDenied, globalResult.unwrapErr(), payload);
16 | return;
17 | }
18 |
19 | // Run command-specific preconditions:
20 | const localResult = await command.preconditions.messageRun(message, command, payload as any);
21 | if (localResult.isErr()) {
22 | message.client.emit(Events.MessageCommandDenied, localResult.unwrapErr(), payload);
23 | return;
24 | }
25 |
26 | message.client.emit(Events.MessageCommandAccepted, payload);
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/optional-listeners/message-command-listeners/CorePrefixedMessage.ts:
--------------------------------------------------------------------------------
1 | import type { Message } from 'discord.js';
2 | import { Listener } from '../../lib/structures/Listener';
3 | import type { MessageCommand } from '../../lib/types/CommandTypes';
4 | import { Events } from '../../lib/types/Events';
5 |
6 | export class CoreListener extends Listener {
7 | public constructor(context: Listener.LoaderContext) {
8 | super(context, { event: Events.PrefixedMessage });
9 | }
10 |
11 | public run(message: Message, prefix: string | RegExp) {
12 | const { client, stores } = this.container;
13 | // Retrieve the command name and validate:
14 | const commandPrefix = this.getCommandPrefix(message.content, prefix);
15 | const prefixLess = message.content.slice(commandPrefix.length).trim();
16 |
17 | // The character that separates the command name from the arguments, this will return -1 when '[p]command' is
18 | // passed, and a non -1 value when '[p]command arg' is passed instead.
19 | const spaceIndex = prefixLess.indexOf(' ');
20 | const commandName = spaceIndex === -1 ? prefixLess : prefixLess.slice(0, spaceIndex);
21 | if (commandName.length === 0) {
22 | client.emit(Events.UnknownMessageCommandName, { message, prefix, commandPrefix });
23 | return;
24 | }
25 |
26 | // Retrieve the command and validate:
27 | const command = stores.get('commands').get(client.options.caseInsensitiveCommands ? commandName.toLowerCase() : commandName);
28 | if (!command) {
29 | client.emit(Events.UnknownMessageCommand, { message, prefix, commandName, commandPrefix });
30 | return;
31 | }
32 |
33 | // If the command exists but is missing a message handler, emit a different event (maybe an application command variant exists)
34 | if (!command.messageRun) {
35 | client.emit(Events.CommandDoesNotHaveMessageCommandHandler, { message, prefix, commandPrefix, command });
36 | return;
37 | }
38 |
39 | // Run the last stage before running the command:
40 | const parameters = spaceIndex === -1 ? '' : prefixLess.substring(spaceIndex + 1).trim();
41 | client.emit(Events.PreMessageCommandRun, {
42 | message,
43 | command: command as MessageCommand,
44 | parameters,
45 | context: { commandName, commandPrefix, prefix }
46 | });
47 | }
48 |
49 | private getCommandPrefix(content: string, prefix: string | RegExp): string {
50 | return typeof prefix === 'string' ? prefix : prefix.exec(content)![0];
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/optional-listeners/message-command-listeners/_load.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import { CoreListener as CoreMessageCommandAccepted } from './CoreMessageCommandAccepted';
3 | import { CoreListener as CoreMessageCommandTyping } from './CoreMessageCommandTyping';
4 | import { CoreListener as CoreMessageCreate } from './CoreMessageCreate';
5 | import { CoreListener as CorePreMessageCommandRun } from './CorePreMessageCommandRun';
6 | import { CoreListener as CorePreMessageParser } from './CorePreMessageParser';
7 | import { CoreListener as CorePrefixedMessage } from './CorePrefixedMessage';
8 |
9 | export function loadMessageCommandListeners() {
10 | const store = 'listeners' as const;
11 | void container.stores.loadPiece({ name: 'CoreMessageCommandAccepted', piece: CoreMessageCommandAccepted, store });
12 | void container.stores.loadPiece({ name: 'CoreMessageCommandTyping', piece: CoreMessageCommandTyping, store });
13 | void container.stores.loadPiece({ name: 'CoreMessageCreate', piece: CoreMessageCreate, store });
14 | void container.stores.loadPiece({ name: 'CorePrefixedMessage', piece: CorePrefixedMessage, store });
15 | void container.stores.loadPiece({ name: 'CorePreMessageCommandRun', piece: CorePreMessageCommandRun, store });
16 | void container.stores.loadPiece({ name: 'CorePreMessageParser', piece: CorePreMessageParser, store });
17 | }
18 |
--------------------------------------------------------------------------------
/src/preconditions/DMOnly.ts:
--------------------------------------------------------------------------------
1 | import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js';
2 | import { Identifiers } from '../lib/errors/Identifiers';
3 | import { AllFlowsPrecondition } from '../lib/structures/Precondition';
4 | import { container } from '@sapphire/pieces';
5 |
6 | export class CorePrecondition extends AllFlowsPrecondition {
7 | public messageRun(message: Message): AllFlowsPrecondition.Result {
8 | return message.guild === null ? this.ok() : this.makeSharedError();
9 | }
10 |
11 | public chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.Result {
12 | return interaction.guildId === null ? this.ok() : this.makeSharedError();
13 | }
14 |
15 | public contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.Result {
16 | return interaction.guildId === null ? this.ok() : this.makeSharedError();
17 | }
18 |
19 | private makeSharedError(): AllFlowsPrecondition.Result {
20 | return this.error({
21 | identifier: Identifiers.PreconditionDMOnly,
22 | message: 'You cannot run this command outside DMs.'
23 | });
24 | }
25 | }
26 |
27 | void container.stores.loadPiece({
28 | name: 'DMOnly',
29 | piece: CorePrecondition,
30 | store: 'preconditions'
31 | });
32 |
--------------------------------------------------------------------------------
/src/preconditions/Enabled.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js';
3 | import { Identifiers } from '../lib/errors/Identifiers';
4 | import type { Command } from '../lib/structures/Command';
5 | import { AllFlowsPrecondition } from '../lib/structures/Precondition';
6 |
7 | export class CorePrecondition extends AllFlowsPrecondition {
8 | public constructor(context: AllFlowsPrecondition.LoaderContext) {
9 | super(context, { position: 10 });
10 | }
11 |
12 | public messageRun(_: Message, command: Command, context: AllFlowsPrecondition.Context): AllFlowsPrecondition.Result {
13 | return command.enabled
14 | ? this.ok()
15 | : this.error({ identifier: Identifiers.CommandDisabled, message: 'This message command is disabled.', context });
16 | }
17 |
18 | public chatInputRun(_: ChatInputCommandInteraction, command: Command, context: AllFlowsPrecondition.Context): AllFlowsPrecondition.Result {
19 | return command.enabled
20 | ? this.ok()
21 | : this.error({ identifier: Identifiers.CommandDisabled, message: 'This chat input command is disabled.', context });
22 | }
23 |
24 | public contextMenuRun(_: ContextMenuCommandInteraction, command: Command, context: AllFlowsPrecondition.Context): AllFlowsPrecondition.Result {
25 | return command.enabled
26 | ? this.ok()
27 | : this.error({ identifier: Identifiers.CommandDisabled, message: 'This context menu command is disabled.', context });
28 | }
29 | }
30 |
31 | void container.stores.loadPiece({
32 | name: 'Enabled',
33 | piece: CorePrecondition,
34 | store: 'preconditions'
35 | });
36 |
--------------------------------------------------------------------------------
/src/preconditions/GuildNewsOnly.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import { ChannelType, ChatInputCommandInteraction, ContextMenuCommandInteraction, Message, type TextBasedChannelTypes } from 'discord.js';
3 | import { Identifiers } from '../lib/errors/Identifiers';
4 | import { AllFlowsPrecondition } from '../lib/structures/Precondition';
5 |
6 | export class CorePrecondition extends AllFlowsPrecondition {
7 | private readonly allowedTypes: TextBasedChannelTypes[] = [ChannelType.GuildAnnouncement, ChannelType.AnnouncementThread];
8 |
9 | public messageRun(message: Message): AllFlowsPrecondition.Result {
10 | return this.allowedTypes.includes(message.channel.type) ? this.ok() : this.makeSharedError();
11 | }
12 |
13 | public async chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.AsyncResult {
14 | const channel = await this.fetchChannelFromInteraction(interaction);
15 |
16 | return this.allowedTypes.includes(channel.type) ? this.ok() : this.makeSharedError();
17 | }
18 |
19 | public async contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.AsyncResult {
20 | const channel = await this.fetchChannelFromInteraction(interaction);
21 |
22 | return this.allowedTypes.includes(channel.type) ? this.ok() : this.makeSharedError();
23 | }
24 |
25 | private makeSharedError(): AllFlowsPrecondition.Result {
26 | return this.error({
27 | identifier: Identifiers.PreconditionGuildNewsOnly,
28 | message: 'You can only run this command in server announcement channels.'
29 | });
30 | }
31 | }
32 |
33 | void container.stores.loadPiece({
34 | name: 'GuildNewsOnly',
35 | piece: CorePrecondition,
36 | store: 'preconditions'
37 | });
38 |
--------------------------------------------------------------------------------
/src/preconditions/GuildNewsThreadOnly.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import { ChannelType, ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js';
3 | import { Identifiers } from '../lib/errors/Identifiers';
4 | import { AllFlowsPrecondition } from '../lib/structures/Precondition';
5 |
6 | export class CorePrecondition extends AllFlowsPrecondition {
7 | public messageRun(message: Message): AllFlowsPrecondition.Result {
8 | return message.thread?.type === ChannelType.AnnouncementThread ? this.ok() : this.makeSharedError();
9 | }
10 |
11 | public async chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.AsyncResult {
12 | const channel = await this.fetchChannelFromInteraction(interaction);
13 | return channel.type === ChannelType.AnnouncementThread ? this.ok() : this.makeSharedError();
14 | }
15 |
16 | public async contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.AsyncResult {
17 | const channel = await this.fetchChannelFromInteraction(interaction);
18 | return channel.type === ChannelType.AnnouncementThread ? this.ok() : this.makeSharedError();
19 | }
20 |
21 | private makeSharedError(): AllFlowsPrecondition.Result {
22 | return this.error({
23 | identifier: Identifiers.PreconditionGuildNewsThreadOnly,
24 | message: 'You can only run this command in server announcement thread channels.'
25 | });
26 | }
27 | }
28 |
29 | void container.stores.loadPiece({
30 | name: 'GuildNewsThreadOnly',
31 | piece: CorePrecondition,
32 | store: 'preconditions'
33 | });
34 |
--------------------------------------------------------------------------------
/src/preconditions/GuildOnly.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js';
3 | import { Identifiers } from '../lib/errors/Identifiers';
4 | import { AllFlowsPrecondition } from '../lib/structures/Precondition';
5 |
6 | export class CorePrecondition extends AllFlowsPrecondition {
7 | public messageRun(message: Message): AllFlowsPrecondition.Result {
8 | return message.guildId === null ? this.makeSharedError() : this.ok();
9 | }
10 |
11 | public chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.Result {
12 | return interaction.guildId === null ? this.makeSharedError() : this.ok();
13 | }
14 |
15 | public contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.Result {
16 | return interaction.guildId === null ? this.makeSharedError() : this.ok();
17 | }
18 |
19 | private makeSharedError(): AllFlowsPrecondition.Result {
20 | return this.error({
21 | identifier: Identifiers.PreconditionGuildOnly,
22 | message: 'You cannot run this command in DMs.'
23 | });
24 | }
25 | }
26 |
27 | void container.stores.loadPiece({
28 | name: 'GuildOnly',
29 | piece: CorePrecondition,
30 | store: 'preconditions'
31 | });
32 |
--------------------------------------------------------------------------------
/src/preconditions/GuildPrivateThreadOnly.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import { ChannelType, ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js';
3 | import { Identifiers } from '../lib/errors/Identifiers';
4 | import { AllFlowsPrecondition } from '../lib/structures/Precondition';
5 |
6 | export class CorePrecondition extends AllFlowsPrecondition {
7 | public messageRun(message: Message): AllFlowsPrecondition.Result {
8 | return message.thread?.type === ChannelType.PrivateThread ? this.ok() : this.makeSharedError();
9 | }
10 |
11 | public async chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.AsyncResult {
12 | const channel = await this.fetchChannelFromInteraction(interaction);
13 | return channel.type === ChannelType.PrivateThread ? this.ok() : this.makeSharedError();
14 | }
15 |
16 | public async contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.AsyncResult {
17 | const channel = await this.fetchChannelFromInteraction(interaction);
18 | return channel.type === ChannelType.PrivateThread ? this.ok() : this.makeSharedError();
19 | }
20 |
21 | private makeSharedError(): AllFlowsPrecondition.Result {
22 | return this.error({
23 | identifier: Identifiers.PreconditionGuildPrivateThreadOnly,
24 | message: 'You can only run this command in private server thread channels.'
25 | });
26 | }
27 | }
28 |
29 | void container.stores.loadPiece({
30 | name: 'GuildPrivateThreadOnly',
31 | piece: CorePrecondition,
32 | store: 'preconditions'
33 | });
34 |
--------------------------------------------------------------------------------
/src/preconditions/GuildPublicThreadOnly.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import { ChannelType, ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js';
3 | import { Identifiers } from '../lib/errors/Identifiers';
4 | import { AllFlowsPrecondition } from '../lib/structures/Precondition';
5 |
6 | export class CorePrecondition extends AllFlowsPrecondition {
7 | public messageRun(message: Message): AllFlowsPrecondition.Result {
8 | return message.thread?.type === ChannelType.PublicThread ? this.ok() : this.makeSharedError();
9 | }
10 |
11 | public async chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.AsyncResult {
12 | const channel = await this.fetchChannelFromInteraction(interaction);
13 | return channel.type === ChannelType.PublicThread ? this.ok() : this.makeSharedError();
14 | }
15 |
16 | public async contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.AsyncResult {
17 | const channel = await this.fetchChannelFromInteraction(interaction);
18 | return channel.type === ChannelType.PublicThread ? this.ok() : this.makeSharedError();
19 | }
20 |
21 | private makeSharedError(): AllFlowsPrecondition.Result {
22 | return this.error({
23 | identifier: Identifiers.PreconditionGuildPublicThreadOnly,
24 | message: 'You can only run this command in public server thread channels.'
25 | });
26 | }
27 | }
28 |
29 | void container.stores.loadPiece({
30 | name: 'GuildPublicThreadOnly',
31 | piece: CorePrecondition,
32 | store: 'preconditions'
33 | });
34 |
--------------------------------------------------------------------------------
/src/preconditions/GuildTextOnly.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import { ChannelType, ChatInputCommandInteraction, ContextMenuCommandInteraction, Message, type TextBasedChannelTypes } from 'discord.js';
3 | import { Identifiers } from '../lib/errors/Identifiers';
4 | import { AllFlowsPrecondition } from '../lib/structures/Precondition';
5 |
6 | export class CorePrecondition extends AllFlowsPrecondition {
7 | private readonly allowedTypes: TextBasedChannelTypes[] = [ChannelType.GuildText, ChannelType.PublicThread, ChannelType.PrivateThread];
8 |
9 | public messageRun(message: Message): AllFlowsPrecondition.Result {
10 | return this.allowedTypes.includes(message.channel.type) ? this.ok() : this.makeSharedError();
11 | }
12 |
13 | public async chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.AsyncResult {
14 | const channel = await this.fetchChannelFromInteraction(interaction);
15 | return this.allowedTypes.includes(channel.type) ? this.ok() : this.makeSharedError();
16 | }
17 |
18 | public async contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.AsyncResult {
19 | const channel = await this.fetchChannelFromInteraction(interaction);
20 | return this.allowedTypes.includes(channel.type) ? this.ok() : this.makeSharedError();
21 | }
22 |
23 | private makeSharedError(): AllFlowsPrecondition.Result {
24 | return this.error({
25 | identifier: Identifiers.PreconditionGuildTextOnly,
26 | message: 'You can only run this command in server text channels.'
27 | });
28 | }
29 | }
30 |
31 | void container.stores.loadPiece({
32 | name: 'GuildTextOnly',
33 | piece: CorePrecondition,
34 | store: 'preconditions'
35 | });
36 |
--------------------------------------------------------------------------------
/src/preconditions/GuildThreadOnly.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js';
3 | import { Identifiers } from '../lib/errors/Identifiers';
4 | import { AllFlowsPrecondition } from '../lib/structures/Precondition';
5 |
6 | export class CorePrecondition extends AllFlowsPrecondition {
7 | public messageRun(message: Message): AllFlowsPrecondition.Result {
8 | return message.thread ? this.ok() : this.makeSharedError();
9 | }
10 |
11 | public async chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.AsyncResult {
12 | const channel = await this.fetchChannelFromInteraction(interaction);
13 | return channel.isThread() ? this.ok() : this.makeSharedError();
14 | }
15 |
16 | public async contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.AsyncResult {
17 | const channel = await this.fetchChannelFromInteraction(interaction);
18 | return channel.isThread() ? this.ok() : this.makeSharedError();
19 | }
20 |
21 | private makeSharedError(): AllFlowsPrecondition.Result {
22 | return this.error({
23 | identifier: Identifiers.PreconditionThreadOnly,
24 | message: 'You can only run this command in server thread channels.'
25 | });
26 | }
27 | }
28 |
29 | void container.stores.loadPiece({
30 | name: 'GuildThreadOnly',
31 | piece: CorePrecondition,
32 | store: 'preconditions'
33 | });
34 |
--------------------------------------------------------------------------------
/src/preconditions/GuildVoiceOnly.ts:
--------------------------------------------------------------------------------
1 | import { isVoiceChannel } from '@sapphire/discord.js-utilities';
2 | import { container } from '@sapphire/pieces';
3 | import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js';
4 | import { Identifiers } from '../lib/errors/Identifiers';
5 | import { AllFlowsPrecondition } from '../lib/structures/Precondition';
6 |
7 | export class CorePrecondition extends AllFlowsPrecondition {
8 | public messageRun(message: Message): AllFlowsPrecondition.Result {
9 | return isVoiceChannel(message.channel) ? this.ok() : this.makeSharedError();
10 | }
11 |
12 | public async chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.AsyncResult {
13 | const channel = await this.fetchChannelFromInteraction(interaction);
14 | return isVoiceChannel(channel) ? this.ok() : this.makeSharedError();
15 | }
16 |
17 | public async contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.AsyncResult {
18 | const channel = await this.fetchChannelFromInteraction(interaction);
19 | return isVoiceChannel(channel) ? this.ok() : this.makeSharedError();
20 | }
21 |
22 | private makeSharedError(): AllFlowsPrecondition.Result {
23 | return this.error({
24 | identifier: Identifiers.PreconditionGuildVoiceOnly,
25 | message: 'You can only run this command in server voice channels.'
26 | });
27 | }
28 | }
29 |
30 | void container.stores.loadPiece({
31 | name: 'GuildVoiceOnly',
32 | piece: CorePrecondition,
33 | store: 'preconditions'
34 | });
35 |
--------------------------------------------------------------------------------
/src/preconditions/NSFW.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js';
3 | import { Identifiers } from '../lib/errors/Identifiers';
4 | import { AllFlowsPrecondition } from '../lib/structures/Precondition';
5 |
6 | export class CorePrecondition extends AllFlowsPrecondition {
7 | public messageRun(message: Message): AllFlowsPrecondition.Result {
8 | // `nsfw` is undefined in DMChannel, doing `=== true`
9 | // will result on it returning `false`.
10 | return Reflect.get(message.channel, 'nsfw') === true
11 | ? this.ok()
12 | : this.error({ identifier: Identifiers.PreconditionNSFW, message: 'You cannot run this message command outside NSFW channels.' });
13 | }
14 |
15 | public async chatInputRun(interaction: ChatInputCommandInteraction): AllFlowsPrecondition.AsyncResult {
16 | const channel = await this.fetchChannelFromInteraction(interaction);
17 |
18 | // `nsfw` is undefined in DMChannel, doing `=== true`
19 | // will result on it returning `false`.
20 | return Reflect.get(channel, 'nsfw') === true
21 | ? this.ok()
22 | : this.error({ identifier: Identifiers.PreconditionNSFW, message: 'You cannot run this chat input command outside NSFW channels.' });
23 | }
24 |
25 | public async contextMenuRun(interaction: ContextMenuCommandInteraction): AllFlowsPrecondition.AsyncResult {
26 | const channel = await this.fetchChannelFromInteraction(interaction);
27 |
28 | // `nsfw` is undefined in DMChannel, doing `=== true`
29 | // will result on it returning `false`.
30 | return Reflect.get(channel, 'nsfw') === true
31 | ? this.ok()
32 | : this.error({ identifier: Identifiers.PreconditionNSFW, message: 'You cannot run this command outside NSFW channels.' });
33 | }
34 | }
35 |
36 | void container.stores.loadPiece({
37 | name: 'NSFW',
38 | piece: CorePrecondition,
39 | store: 'preconditions'
40 | });
41 |
--------------------------------------------------------------------------------
/src/preconditions/RunIn.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@sapphire/pieces';
2 | import type { ChatInputCommandInteraction, ContextMenuCommandInteraction, Message } from 'discord.js';
3 | import { Identifiers } from '../lib/errors/Identifiers';
4 | import { Command } from '../lib/structures/Command';
5 | import { AllFlowsPrecondition, type Preconditions } from '../lib/structures/Precondition';
6 | import type { ChatInputCommand, ContextMenuCommand, MessageCommand } from '../lib/types/CommandTypes';
7 |
8 | export interface RunInPreconditionContext extends AllFlowsPrecondition.Context {
9 | types?: Preconditions['RunIn']['types'];
10 | }
11 |
12 | export class CorePrecondition extends AllFlowsPrecondition {
13 | public override messageRun(message: Message, _: MessageCommand, context: RunInPreconditionContext): AllFlowsPrecondition.Result {
14 | const commandType = 'message';
15 | if (!context.types) return this.ok();
16 |
17 | const channelType = message.channel.type;
18 |
19 | if (Command.runInTypeIsSpecificsObject(context.types)) {
20 | return context.types.messageRun.includes(channelType) ? this.ok() : this.makeSharedError(context, commandType);
21 | }
22 |
23 | return context.types.includes(channelType) ? this.ok() : this.makeSharedError(context, commandType);
24 | }
25 |
26 | public override async chatInputRun(
27 | interaction: ChatInputCommandInteraction,
28 | _: ChatInputCommand,
29 | context: RunInPreconditionContext
30 | ): AllFlowsPrecondition.AsyncResult {
31 | const commandType = 'chat input';
32 | if (!context.types) return this.ok();
33 |
34 | const channelType = (await this.fetchChannelFromInteraction(interaction)).type;
35 |
36 | if (Command.runInTypeIsSpecificsObject(context.types)) {
37 | return context.types.chatInputRun.includes(channelType) ? this.ok() : this.makeSharedError(context, commandType);
38 | }
39 |
40 | return context.types.includes(channelType) ? this.ok() : this.makeSharedError(context, commandType);
41 | }
42 |
43 | public override async contextMenuRun(
44 | interaction: ContextMenuCommandInteraction,
45 | _: ContextMenuCommand,
46 | context: RunInPreconditionContext
47 | ): AllFlowsPrecondition.AsyncResult {
48 | const commandType = 'context menu';
49 | if (!context.types) return this.ok();
50 |
51 | const channelType = (await this.fetchChannelFromInteraction(interaction)).type;
52 |
53 | if (Command.runInTypeIsSpecificsObject(context.types)) {
54 | return context.types.contextMenuRun.includes(channelType) ? this.ok() : this.makeSharedError(context, commandType);
55 | }
56 |
57 | return context.types.includes(channelType) ? this.ok() : this.makeSharedError(context, commandType);
58 | }
59 |
60 | private makeSharedError(context: RunInPreconditionContext, commandType: string): AllFlowsPrecondition.Result {
61 | return this.error({
62 | identifier: Identifiers.PreconditionRunIn,
63 | message: `You cannot run this ${commandType} command in this type of channel.`,
64 | context: { types: context.types }
65 | });
66 | }
67 | }
68 |
69 | void container.stores.loadPiece({
70 | name: 'RunIn',
71 | piece: CorePrecondition,
72 | store: 'preconditions'
73 | });
74 |
--------------------------------------------------------------------------------
/src/preconditions/_load.ts:
--------------------------------------------------------------------------------
1 | import './ClientPermissions';
2 | import './Cooldown';
3 | import './DMOnly';
4 | import './Enabled';
5 | import './GuildNewsOnly';
6 | import './GuildNewsThreadOnly';
7 | import './GuildOnly';
8 | import './GuildPrivateThreadOnly';
9 | import './GuildPublicThreadOnly';
10 | import './GuildTextOnly';
11 | import './GuildThreadOnly';
12 | import './GuildVoiceOnly';
13 | import './NSFW';
14 | import './RunIn';
15 | import './UserPermissions';
16 |
--------------------------------------------------------------------------------
/src/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.base.json",
3 | "compilerOptions": {
4 | "rootDir": "./"
5 | },
6 | "include": ["."]
7 | }
8 |
--------------------------------------------------------------------------------
/tests/Flags.test.ts:
--------------------------------------------------------------------------------
1 | import { Lexer, Parser } from '@sapphire/lexure';
2 | import { FlagUnorderedStrategy } from '../src/lib/utils/strategies/FlagUnorderedStrategy';
3 |
4 | const parse = (testString: string) =>
5 | new Parser(new FlagUnorderedStrategy({ flags: ['f', 'hello'], options: ['o', 'option'] })).run(
6 | new Lexer({
7 | quotes: [
8 | ['"', '"'],
9 | ['“', '”'],
10 | ['「', '」'],
11 | ['«', '»']
12 | ]
13 | }).run(testString)
14 | );
15 |
16 | describe('Flag parsing strategy', () => {
17 | test('GIVEN typeof FlagStrategy THEN returns function', () => {
18 | expect(typeof FlagUnorderedStrategy).toBe('function');
19 | });
20 | test('GIVEN flag without value THEN returns flag', () => {
21 | const { flags, options } = parse('-f');
22 | expect(flags.size).toBe(1);
23 | expect([...flags]).toStrictEqual(['f']);
24 | expect(options.size).toBe(0);
25 | });
26 |
27 | test('GIVEN flag without value inside text THEN returns flag', () => {
28 | const { flags, options } = parse('commit "hello there" -f');
29 | expect(flags.size).toBe(1);
30 | expect([...flags]).toStrictEqual(['f']);
31 | expect(options.size).toBe(0);
32 | });
33 |
34 | test('GIVEN flag with value THEN returns nothing', () => {
35 | const { flags, options } = parse('-f=hi');
36 | expect(flags.size).toBe(0);
37 | expect(options.size).toBe(0);
38 | });
39 |
40 | test('GIVEN flag with value inside text THEN returns nothing', () => {
41 | const { flags, options } = parse('commit "hello there" -f=hi');
42 | expect(flags.size).toBe(0);
43 | expect(options.size).toBe(0);
44 | });
45 |
46 | test('GIVEN option with value THEN returns option', () => {
47 | const { flags, options } = parse('--option=world');
48 | expect(flags.size).toBe(0);
49 | expect(options.size).toBe(1);
50 | expect(options.has('option')).toBe(true);
51 | expect(options.get('option')).toStrictEqual(['world']);
52 | });
53 |
54 | test('GIVEN option with value inside text THEN returns option with single value', () => {
55 | const { flags, options } = parse('command --option=world');
56 | expect(flags.size).toBe(0);
57 | expect(options.size).toBe(1);
58 | expect(options.has('option')).toBe(true);
59 | expect(options.get('option')).toStrictEqual(['world']);
60 | });
61 |
62 | test('GIVEN option with multiple occurences inside text THEN returns option with multiple values', () => {
63 | const { flags, options } = parse('command --option=world --option=sammy');
64 | expect(flags.size).toBe(0);
65 | expect(options.size).toBe(1);
66 | expect(options.has('option')).toBe(true);
67 | expect(options.get('option')).toStrictEqual(['world', 'sammy']);
68 | });
69 |
70 | test('GIVEN flag inside quotes THEN returns nothing', () => {
71 | const { flags, options } = parse('commit "hello there -f"');
72 | expect(flags.size).toBe(0);
73 | expect(options.size).toBe(0);
74 | });
75 |
76 | test('GIVEN option without value inside quote THEN returns nothing', () => {
77 | const { flags, options } = parse('mention "try --hello"');
78 | expect(flags.size).toBe(0);
79 | expect(options.size).toBe(0);
80 | });
81 | });
82 |
--------------------------------------------------------------------------------
/tests/precondition-resolvers/clientPermissions.test.ts:
--------------------------------------------------------------------------------
1 | import { PermissionFlagsBits } from 'discord.js';
2 | import { parseConstructorPreConditionsRequiredClientPermissions } from '../../src/lib/precondition-resolvers/clientPermissions';
3 | import { CommandPreConditions } from '../../src/lib/types/Enums';
4 | import { PreconditionContainerArray } from '../../src/lib/utils/preconditions/PreconditionContainerArray';
5 | import type { PreconditionContainerSingle } from '../../src/lib/utils/preconditions/PreconditionContainerSingle';
6 | import type { PermissionPreconditionContext } from '../../src/preconditions/ClientPermissions';
7 |
8 | describe('parseConstructorPreConditionsRequiredClientPermissions', () => {
9 | test('GIVEN valid permissions THEN appends to preconditionContainerArray', () => {
10 | const preconditionContainerArray = new PreconditionContainerArray();
11 | parseConstructorPreConditionsRequiredClientPermissions(PermissionFlagsBits.Administrator, preconditionContainerArray);
12 | expect(preconditionContainerArray.entries.length).toBe(1);
13 | expect((preconditionContainerArray.entries[0] as PreconditionContainerSingle).name).toBe(CommandPreConditions.ClientPermissions);
14 | expect(
15 | ((preconditionContainerArray.entries[0] as PreconditionContainerSingle).context as PermissionPreconditionContext).permissions?.has(
16 | PermissionFlagsBits.Administrator
17 | )
18 | ).toBe(true);
19 | });
20 |
21 | test('GIVEN no permissions THEN does not append to preconditionContainerArray', () => {
22 | const preconditionContainerArray = new PreconditionContainerArray();
23 | parseConstructorPreConditionsRequiredClientPermissions(undefined, preconditionContainerArray);
24 | expect(preconditionContainerArray.entries.length).toBe(0);
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/tests/precondition-resolvers/nsfw.test.ts:
--------------------------------------------------------------------------------
1 | import { parseConstructorPreConditionsNsfw } from '../../src/lib/precondition-resolvers/nsfw';
2 | import { CommandPreConditions } from '../../src/lib/types/Enums';
3 | import { PreconditionContainerArray } from '../../src/lib/utils/preconditions/PreconditionContainerArray';
4 |
5 | describe('parseConstructorPreConditionsNsfw', () => {
6 | test('GIVEN nsfw true THEN appends to preconditionContainerArray', () => {
7 | const preconditionContainerArray = new PreconditionContainerArray();
8 | parseConstructorPreConditionsNsfw(true, preconditionContainerArray);
9 | expect(preconditionContainerArray.entries.length).toBe(1);
10 | expect((preconditionContainerArray.entries[0] as any).name).toBe(CommandPreConditions.NotSafeForWork);
11 | });
12 |
13 | test('GIVEN nsfw false THEN does not append to preconditionContainerArray', () => {
14 | const preconditionContainerArray = new PreconditionContainerArray();
15 | parseConstructorPreConditionsNsfw(false, preconditionContainerArray);
16 | expect(preconditionContainerArray.entries.length).toBe(0);
17 | });
18 |
19 | test('GIVEN nsfw undefined THEN does not append to preconditionContainerArray', () => {
20 | const preconditionContainerArray = new PreconditionContainerArray();
21 | parseConstructorPreConditionsNsfw(undefined, preconditionContainerArray);
22 | expect(preconditionContainerArray.entries.length).toBe(0);
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/tests/precondition-resolvers/userPermissions.test.ts:
--------------------------------------------------------------------------------
1 | import { PermissionFlagsBits } from 'discord.js';
2 | import { parseConstructorPreConditionsRequiredUserPermissions } from '../../src/lib/precondition-resolvers/userPermissions';
3 | import { CommandPreConditions } from '../../src/lib/types/Enums';
4 | import { PreconditionContainerArray } from '../../src/lib/utils/preconditions/PreconditionContainerArray';
5 | import type { PreconditionContainerSingle } from '../../src/lib/utils/preconditions/PreconditionContainerSingle';
6 | import type { PermissionPreconditionContext } from '../../src/preconditions/ClientPermissions';
7 |
8 | describe('parseConstructorPreConditionsRequiredUserPermissions', () => {
9 | test('GIVEN valid permissions THEN appends to preconditionContainerArray', () => {
10 | const preconditionContainerArray = new PreconditionContainerArray();
11 | parseConstructorPreConditionsRequiredUserPermissions(PermissionFlagsBits.Administrator, preconditionContainerArray);
12 | expect(preconditionContainerArray.entries.length).toBe(1);
13 | expect((preconditionContainerArray.entries[0] as PreconditionContainerSingle).name).toBe(CommandPreConditions.UserPermissions);
14 | expect(
15 | ((preconditionContainerArray.entries[0] as PreconditionContainerSingle).context as PermissionPreconditionContext).permissions?.has(
16 | PermissionFlagsBits.Administrator
17 | )
18 | ).toBe(true);
19 | });
20 |
21 | test('GIVEN no permissions THEN does not append to preconditionContainerArray', () => {
22 | const preconditionContainerArray = new PreconditionContainerArray();
23 | parseConstructorPreConditionsRequiredUserPermissions(undefined, preconditionContainerArray);
24 | expect(preconditionContainerArray.entries.length).toBe(0);
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/tests/resolvers/boolean.test.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import { Identifiers } from '../../src/lib/errors/Identifiers';
3 | import { resolveBoolean } from '../../src/lib/resolvers/boolean';
4 |
5 | describe('Boolean resolver tests', () => {
6 | test('GIVEN a truthy value THEN returns true', () => {
7 | expect(resolveBoolean('true')).toEqual(Result.ok(true));
8 | expect(resolveBoolean('1')).toEqual(Result.ok(true));
9 | expect(resolveBoolean('+')).toEqual(Result.ok(true));
10 | expect(resolveBoolean('yes')).toEqual(Result.ok(true));
11 | });
12 |
13 | test('GIVEN a falsy value THEN returns false', () => {
14 | expect(resolveBoolean('false')).toEqual(Result.ok(false));
15 | expect(resolveBoolean('0')).toEqual(Result.ok(false));
16 | expect(resolveBoolean('-')).toEqual(Result.ok(false));
17 | expect(resolveBoolean('no')).toEqual(Result.ok(false));
18 | });
19 |
20 | test('GIVEN a truthy value with custom ones THEN returns true', () => {
21 | expect(resolveBoolean('yay', { truths: ['yay'] })).toEqual(Result.ok(true));
22 | expect(resolveBoolean('yup', { truths: ['yay', 'yup', 'yop'] })).toEqual(Result.ok(true));
23 | });
24 |
25 | test('GIVEN a falsy value with custom ones THEN returns false', () => {
26 | expect(resolveBoolean('nah', { falses: ['nah'] })).toEqual(Result.ok(false));
27 | expect(resolveBoolean('nope', { falses: ['nah', 'nope', 'noooo'] })).toEqual(Result.ok(false));
28 | });
29 |
30 | test('GIVEN an invalid values THEN returns error', () => {
31 | expect(resolveBoolean('hello')).toEqual(Result.err(Identifiers.ArgumentBooleanError));
32 | expect(resolveBoolean('world', { truths: ['nah', 'nope', 'noooo'] })).toEqual(Result.err(Identifiers.ArgumentBooleanError));
33 | });
34 | });
35 |
--------------------------------------------------------------------------------
/tests/resolvers/date.test.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import { Identifiers } from '../../src/lib/errors/Identifiers';
3 | import { resolveDate } from '../../src/lib/resolvers/date';
4 |
5 | const DATE_2018_PLAIN_STRING = 'August 11, 2018 00:00:00';
6 | const DATE_2018 = new Date(DATE_2018_PLAIN_STRING);
7 |
8 | const DATE_2020_PLAIN_STRING = 'August 11, 2020 00:00:00';
9 | const DATE_2020 = new Date(DATE_2020_PLAIN_STRING);
10 |
11 | const DATE_2022_PLAIN_STRING = 'August 11, 2022 00:00:00';
12 | const DATE_2022 = new Date(DATE_2022_PLAIN_STRING);
13 |
14 | const MINIMUM = { minimum: new Date('August 11, 2019 00:00:00').getTime() };
15 | const MAXIMUM = { maximum: new Date('August 11, 2021 00:00:00').getTime() };
16 |
17 | describe('Date resolver tests', () => {
18 | test('GIVEN a valid date-time THEN returns the associated timestamp', () => {
19 | expect(resolveDate(DATE_2020_PLAIN_STRING)).toEqual(Result.ok(DATE_2020));
20 | });
21 | test('GIVEN a valid date-time with minimum THEN returns the associated timestamp', () => {
22 | expect(resolveDate(DATE_2022_PLAIN_STRING, MINIMUM)).toEqual(Result.ok(DATE_2022));
23 | });
24 | test('GIVEN a valid date-time with maximum THEN returns the associated timestamp', () => {
25 | expect(resolveDate(DATE_2018_PLAIN_STRING, MAXIMUM)).toEqual(Result.ok(DATE_2018));
26 | });
27 | test('GIVEN a date-time before minimum THEN returns error', () => {
28 | expect(resolveDate(DATE_2018_PLAIN_STRING, MINIMUM)).toEqual(Result.err(Identifiers.ArgumentDateTooEarly));
29 | });
30 | test('GIVEN a date-time beyond maximum THEN returns error', () => {
31 | expect(resolveDate(DATE_2022_PLAIN_STRING, MAXIMUM)).toEqual(Result.err(Identifiers.ArgumentDateTooFar));
32 | });
33 | test('GIVEN an invalid date THEN returns error', () => {
34 | expect(resolveDate('hello')).toEqual(Result.err(Identifiers.ArgumentDateError));
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/tests/resolvers/emoji.test.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import { Identifiers } from '../../src/lib/errors/Identifiers';
3 | import { resolveEmoji } from '../../src/lib/resolvers/emoji';
4 |
5 | describe('Emoji resolver tests', () => {
6 | test('GIVEN an unicode emoji THEN returns emojiObject', () => {
7 | const resolvedEmoji = resolveEmoji('😄');
8 | expect(resolvedEmoji.isOk()).toBe(true);
9 | expect(() => resolvedEmoji.unwrapErr()).toThrowError();
10 | expect(resolvedEmoji.unwrap()).toMatchObject({ id: null, name: '😄' });
11 | });
12 | test('GIVEN a string emoji THEN returns ArgumentEmojiError', () => {
13 | const resolvedEmoji = resolveEmoji(':smile:');
14 | expect(resolvedEmoji).toEqual(Result.err(Identifiers.ArgumentEmojiError));
15 | });
16 | test('GIVEN a string THEN returns ArgumentEmojiError', () => {
17 | const resolvedEmoji = resolveEmoji('foo');
18 | expect(resolvedEmoji).toEqual(Result.err(Identifiers.ArgumentEmojiError));
19 | });
20 | test('GIVEN a wrongly formatted string custom emoji THEN returns ArgumentEmojiError', () => {
21 | const resolvedEmoji = resolveEmoji('');
22 | expect(resolvedEmoji).toEqual(Result.err(Identifiers.ArgumentEmojiError));
23 | });
24 | test('GIVEN a string custom emoji THEN returns emojiObject', () => {
25 | const resolvedEmoji = resolveEmoji('<:custom:737141877803057244>');
26 | expect(resolvedEmoji.isOk()).toBe(true);
27 | expect(() => resolvedEmoji.unwrapErr()).toThrowError();
28 | expect(resolvedEmoji.unwrap()).toMatchObject({ id: '737141877803057244', name: 'custom' });
29 | });
30 | test('GIVEN a string custom animated emoji THEN returns emojiObject', () => {
31 | const resolvedEmoji = resolveEmoji('');
32 | expect(resolvedEmoji.isOk()).toBe(true);
33 | expect(() => resolvedEmoji.unwrapErr()).toThrowError();
34 | expect(resolvedEmoji.unwrap()).toMatchObject({ animated: true, id: '737141877803057244', name: 'custom' });
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/tests/resolvers/enum.test.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import { Identifiers } from '../../src/lib/errors/Identifiers';
3 | import { resolveEnum } from '../../src/lib/resolvers/enum';
4 |
5 | describe('Enum resolver tests', () => {
6 | test('GIVEN good lowercase enum from one option THEN returns string', () => {
7 | const resolvedEnum = resolveEnum('foo', { enum: ['foo'] });
8 | expect(resolvedEnum).toEqual(Result.ok('foo'));
9 | });
10 | test('GIVEN good mixedcase enum from one option THEN returns string', () => {
11 | const resolvedEnum = resolveEnum('FoO', { enum: ['FoO'] });
12 | expect(resolvedEnum).toEqual(Result.ok('FoO'));
13 | });
14 | test('GIVEN good enum from more options THEN returns string', () => {
15 | const resolvedEnum = resolveEnum('foo', { enum: ['foo', 'bar', 'baz'] });
16 | expect(resolvedEnum).toEqual(Result.ok('foo'));
17 | });
18 | test('GIVEN good case insensitive enum from more options THEN returns string', () => {
19 | const resolvedEnum = resolveEnum('FoO', { enum: ['FoO', 'foo', 'bar', 'baz'], caseInsensitive: false });
20 | expect(resolvedEnum).toEqual(Result.ok('FoO'));
21 | });
22 | test('GIVEN good enum from one option THEN returns ArgumentEnumError', () => {
23 | const resolvedEnum = resolveEnum('foo', { enum: ['foo'] });
24 | expect(resolvedEnum.isOk()).toBe(true);
25 | });
26 | test('GIVEN an empty enum array THEN returns ArgumentEnumEmptyError', () => {
27 | const resolvedEnum = resolveEnum('foo');
28 | expect(resolvedEnum).toEqual(Result.err(Identifiers.ArgumentEnumEmptyError));
29 | });
30 | test('GIVEN an enum not listed in the array THEN returns ArgumentEnumError', () => {
31 | const resolvedEnum = resolveEnum('foo', { enum: ['bar', 'baz'] });
32 | expect(resolvedEnum).toEqual(Result.err(Identifiers.ArgumentEnumError));
33 | });
34 | test('GIVEN an enum with wrong case THEN returns ArgumentEnumError', () => {
35 | const resolvedEnum = resolveEnum('FOO', { enum: ['bar', 'baz'], caseInsensitive: false });
36 | expect(resolvedEnum).toEqual(Result.err(Identifiers.ArgumentEnumError));
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/tests/resolvers/float.test.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import { Identifiers } from '../../src/lib/errors/Identifiers';
3 | import { resolveFloat } from '../../src/lib/resolvers/float';
4 |
5 | describe('Float resolver tests', () => {
6 | test('GIVEN a valid float THEN returns its parsed value', () => {
7 | expect(resolveFloat('1.23')).toEqual(Result.ok(1.23));
8 | });
9 | test('GIVEN a valid float with minimum THEN returns its parsed value', () => {
10 | expect(resolveFloat('2.34', { minimum: 2 })).toEqual(Result.ok(2.34));
11 | });
12 | test('GIVEN a valid float with maximum THEN returns its parsed value', () => {
13 | expect(resolveFloat('3.45', { maximum: 4 })).toEqual(Result.ok(3.45));
14 | });
15 | test('GIVEN a float before minimum THEN returns error', () => {
16 | expect(resolveFloat('1.23', { minimum: 2 })).toEqual(Result.err(Identifiers.ArgumentFloatTooSmall));
17 | });
18 | test('GIVEN a float beyond maximum THEN returns error', () => {
19 | expect(resolveFloat('4.56', { maximum: 4 })).toEqual(Result.err(Identifiers.ArgumentFloatTooLarge));
20 | });
21 | test('GIVEN an invalid float THEN returns error', () => {
22 | expect(resolveFloat('hello')).toEqual(Result.err(Identifiers.ArgumentFloatError));
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/tests/resolvers/hyperlink.test.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import { URL } from 'node:url';
3 | import { Identifiers } from '../../src/lib/errors/Identifiers';
4 | import { resolveHyperlink } from '../../src/lib/resolvers/hyperlink';
5 |
6 | const STRING_URL = 'https://github.com/sapphiredev';
7 | const PARSED_URL = new URL(STRING_URL);
8 |
9 | describe('Hyperlink resolver tests', () => {
10 | test('GIVEN a valid hyperlink THEN returns its parsed value', () => {
11 | expect(resolveHyperlink(STRING_URL)).toEqual(Result.ok(PARSED_URL));
12 | });
13 | test('GIVEN an invalid hyperlink THEN returns error', () => {
14 | expect(resolveHyperlink('hello')).toEqual(Result.err(Identifiers.ArgumentHyperlinkError));
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/tests/resolvers/integer.test.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import { Identifiers } from '../../src/lib/errors/Identifiers';
3 | import { resolveInteger } from '../../src/lib/resolvers/integer';
4 |
5 | describe('Integer resolver tests', () => {
6 | test('GIVEN a valid integer THEN returns its parsed value', () => {
7 | expect(resolveInteger('1')).toEqual(Result.ok(1));
8 | });
9 | test('GIVEN a valid integer with minimum THEN returns its parsed value', () => {
10 | expect(resolveInteger('2', { minimum: 2 })).toEqual(Result.ok(2));
11 | });
12 | test('GIVEN a valid integer with maximum THEN returns its parsed value', () => {
13 | expect(resolveInteger('3', { maximum: 4 })).toEqual(Result.ok(3));
14 | });
15 | test('GIVEN a integer before minimum THEN returns error', () => {
16 | expect(resolveInteger('1', { minimum: 2 })).toEqual(Result.err(Identifiers.ArgumentIntegerTooSmall));
17 | });
18 | test('GIVEN a integer beyond maximum THEN returns error', () => {
19 | expect(resolveInteger('5', { maximum: 4 })).toEqual(Result.err(Identifiers.ArgumentIntegerTooLarge));
20 | });
21 | test('GIVEN an invalid integer THEN returns error', () => {
22 | expect(resolveInteger('hello')).toEqual(Result.err(Identifiers.ArgumentIntegerError));
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/tests/resolvers/number.test.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import { Identifiers } from '../../src/lib/errors/Identifiers';
3 | import { resolveNumber } from '../../src/lib/resolvers/number';
4 |
5 | describe('Number resolver tests', () => {
6 | test('GIVEN a valid number THEN returns its parsed value', () => {
7 | expect(resolveNumber('1.23')).toEqual(Result.ok(1.23));
8 |
9 | expect(resolveNumber('1')).toEqual(Result.ok(1));
10 | });
11 | test('GIVEN a valid number with minimum THEN returns its parsed value', () => {
12 | expect(resolveNumber('2.34', { minimum: 2 })).toEqual(Result.ok(2.34));
13 |
14 | expect(resolveNumber('2', { minimum: 2 })).toEqual(Result.ok(2));
15 | });
16 | test('GIVEN a valid number with maximum THEN returns its parsed value', () => {
17 | expect(resolveNumber('3.45', { maximum: 4 })).toEqual(Result.ok(3.45));
18 |
19 | expect(resolveNumber('3', { maximum: 4 })).toEqual(Result.ok(3));
20 | });
21 | test('GIVEN a number smaller than minimum THEN returns error', () => {
22 | expect(resolveNumber('1.23', { minimum: 2 })).toEqual(Result.err(Identifiers.ArgumentNumberTooSmall));
23 |
24 | expect(resolveNumber('1', { minimum: 2 })).toEqual(Result.err(Identifiers.ArgumentNumberTooSmall));
25 | });
26 | test('GIVEN a number larger than maximum THEN returns error', () => {
27 | expect(resolveNumber('4.56', { maximum: 4 })).toEqual(Result.err(Identifiers.ArgumentNumberTooLarge));
28 |
29 | expect(resolveNumber('5', { maximum: 4 })).toEqual(Result.err(Identifiers.ArgumentNumberTooLarge));
30 | });
31 | test('GIVEN an invalid number THEN returns error', () => {
32 | expect(resolveNumber('hello')).toEqual(Result.err(Identifiers.ArgumentNumberError));
33 | });
34 | });
35 |
--------------------------------------------------------------------------------
/tests/resolvers/string.test.ts:
--------------------------------------------------------------------------------
1 | import { Result } from '@sapphire/result';
2 | import { Identifiers } from '../../src/lib/errors/Identifiers';
3 | import { resolveString } from '../../src/lib/resolvers/string';
4 |
5 | describe('String resolver tests', () => {
6 | test('GIVEN a valid string THEN returns it', () => {
7 | expect(resolveString('hello')).toEqual(Result.ok('hello'));
8 |
9 | expect(resolveString('100')).toEqual(Result.ok('100'));
10 | });
11 | test('GIVEN a valid string with minimum THEN returns it', () => {
12 | expect(resolveString('hello', { minimum: 2 })).toEqual(Result.ok('hello'));
13 |
14 | expect(resolveString('100', { minimum: 2 })).toEqual(Result.ok('100'));
15 | });
16 | test('GIVEN a valid string with maximum THEN returns its parsed value', () => {
17 | expect(resolveString('hello', { maximum: 10 })).toEqual(Result.ok('hello'));
18 |
19 | expect(resolveString('100', { maximum: 100 })).toEqual(Result.ok('100'));
20 | });
21 | test('GIVEN a string shorter than minimum THEN returns error', () => {
22 | expect(resolveString('hello', { minimum: 10 })).toEqual(Result.err(Identifiers.ArgumentStringTooShort));
23 |
24 | expect(resolveString('100', { minimum: 10 })).toEqual(Result.err(Identifiers.ArgumentStringTooShort));
25 | });
26 | test('GIVEN a string longer than maximum THEN returns error', () => {
27 | expect(resolveString('hello', { maximum: 2 })).toEqual(Result.err(Identifiers.ArgumentStringTooLong));
28 |
29 | expect(resolveString('100', { maximum: 2 })).toEqual(Result.err(Identifiers.ArgumentStringTooLong));
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/tests/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.base.json",
3 | "compilerOptions": {
4 | "types": ["vitest/globals"]
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@sapphire/ts-config", "@sapphire/ts-config/bundler", "@sapphire/ts-config/extra-strict", "@sapphire/ts-config/verbatim"],
3 | "compilerOptions": {
4 | "noEmit": true,
5 | "incremental": false,
6 | "useDefineForClassFields": false
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.base.json",
3 | "compilerOptions": {
4 | "types": ["vitest/globals"],
5 | "lib": ["DOM", "ESNext"]
6 | },
7 | "include": ["src", "tests", "scripts", "vitest.config.ts", "tsup.config.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { esbuildPluginFilePathExtensions } from 'esbuild-plugin-file-path-extensions';
2 | import { esbuildPluginVersionInjector } from 'esbuild-plugin-version-injector';
3 | import { defineConfig, type Options } from 'tsup';
4 |
5 | const baseOptions: Options = {
6 | clean: true,
7 | entry: ['src/**/*.ts'],
8 | dts: true,
9 | minify: false,
10 | skipNodeModulesBundle: true,
11 | sourcemap: true,
12 | target: 'es2021',
13 | tsconfig: 'src/tsconfig.json',
14 | keepNames: true,
15 | esbuildPlugins: [esbuildPluginVersionInjector(), esbuildPluginFilePathExtensions()],
16 | treeshake: true
17 | };
18 |
19 | export default [
20 | defineConfig({
21 | ...baseOptions,
22 | outDir: 'dist/cjs',
23 | format: 'cjs',
24 | outExtension: () => ({ js: '.cjs' })
25 | }),
26 | defineConfig({
27 | ...baseOptions,
28 | outDir: 'dist/esm',
29 | format: 'esm'
30 | })
31 | ];
32 |
--------------------------------------------------------------------------------
/typedoc.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://typedoc.org/schema.json",
3 | "entryPoints": ["src/index.ts"],
4 | "json": "docs/api.json",
5 | "tsconfig": "src/tsconfig.json",
6 | "excludePrivate": false
7 | }
8 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 |
3 | export default defineConfig({
4 | test: {
5 | globals: true,
6 | coverage: {
7 | enabled: true,
8 | reporter: ['text', 'lcov']
9 | }
10 | },
11 | esbuild: {
12 | target: 'es2022'
13 | }
14 | });
15 |
--------------------------------------------------------------------------------