diff --git a/.changeset/gateway-application-commands.md b/.changeset/gateway-application-commands.md new file mode 100644 index 00000000..b4de9a86 --- /dev/null +++ b/.changeset/gateway-application-commands.md @@ -0,0 +1,5 @@ +--- +"@wolfstar/plugin-gateway": minor +--- + +Add `ApplicationCommand` and its managers, fetch-only like `WebhookManager`: `client.application.commands` (`ApplicationCommandManager`) and `guild.commands` (`GuildApplicationCommandManager`, a new instance on every access, also `client.guilds.commands(guildId)`) have `fetch`, `create`, `set`, `edit`, `delete`, `commandPath` and the static `transformCommand`, which accepts camel-cased data, raw payloads and builders. `ApplicationCommand` has the command's fields, `edit()`, the `set*()` helpers, `delete()`, `permissions`, `equals(command, enforceOptionOrder?)` (`false` for a foreign argument) and the static `optionsEqual()` and `transformOption()`. `ApplicationCommandPermissionsManager` (`fetch`, `has`, and `set`, `add` and `remove` taking an explicit bearer `token`, with no OAuth handling) reads through the existing `applicationCommandPermissions` cache, kept up to date by `APPLICATION_COMMAND_PERMISSIONS_UPDATE`. `GatewayError` gains the `ApplicationCommandPermissionsTokenMissing` and `GlobalCommandPermissions` codes. `set()` is the bulk overwrite and replaces whatever `@wolfstar/http-framework` registered, nothing calls it for you. diff --git a/packages/plugin-gateway/README.md b/packages/plugin-gateway/README.md index 5cd2bd34..eb745445 100644 --- a/packages/plugin-gateway/README.md +++ b/packages/plugin-gateway/README.md @@ -1099,8 +1099,63 @@ await client.application.editRoleConnectionMetadataRecords(records); ``` `owner` is the `Team` (with its `members`, each a `TeamMember`) when a team owns the application, the owner `User` -otherwise. `application.commands` and `application.emojis` will be added to `ClientApplication` by their own -features, there are no placeholders for them. +otherwise. `application.emojis` will be added to `ClientApplication` by its own feature, there is no +placeholder for it. + +### Application commands + +`client.application.commands` (global commands, and the ones of any guild through a `guildId` option) and +`guild.commands` (one guild, a new manager on every access, also `client.guilds.commands(guildId)`) manage +`ApplicationCommand`s through the API. They are **fetch-only**: Discord sends no event when a command changes, so a +cache could not be kept in sync, and every method calls the API and returns new structures. The only cached piece +is the permissions, see below. + +```ts +const commands = await client.application.commands.fetch(); // Collection +const one = await guild.commands.fetch("123456789012345678"); +const localized = await client.application.commands.fetch({ + locale: "fr", + withLocalizations: true, +}); + +const created = await guild.commands.create({ + name: "awoo", + description: "Howl", + defaultMemberPermissions: ["ManageGuild"], // or a builder: create(builder), anything with toJSON() + options: [{ type: ApplicationCommandOptionType.Boolean, name: "loud", description: "Howl loud" }], +}); +await created.setDescription("Howl at the moon"); // patches `created` in place +await created.delete(); + +existing.equals(desired); // compares every field `desired` has, options recursively +existing.equals(desired, true); // also their order +``` + +> [!WARNING] +> `set(commands)` is the bulk overwrite: it replaces every command of the application (or the guild) with the given +> ones. `@wolfstar/http-framework` registers the commands of its pieces with the very same call, so `set()` deletes +> whatever the framework registered. Nothing in this package calls it: use it to manage commands the framework does not, +> or instead of it. + +`ApplicationCommandPermissionsManager` is `application.commands.permissions` (any guild and command: pass `guild` and +`command`), `guild.commands.permissions` (pass `command`) and `command.permissions`. `fetch` and `has` use the bot's +authorization and, for one command, the `applicationCommandPermissions` cache (kept up to date by +`APPLICATION_COMMAND_PERMISSIONS_UPDATE`, and by what these methods fetch and write), unless `fetch` is given +`force`. Discord +only accepts writes with an OAuth2 bearer token of a user able to manage the guild, with the +`applications.commands.permissions.update` scope, so `set`, `add` and `remove` take it as a `token` option and throw +`ApplicationCommandPermissionsTokenMissing` without one. Getting the token is up to you, there is no OAuth2 flow here. +Roles and channels are given as IDs or structures, nothing is looked up through the guild. + +```ts +await command.permissions.fetch({ guild: guildId, force: true }); +await guild.commands.permissions.has({ command, permissionId: roleId }); +await guild.commands.permissions.add({ + command, + token: userToken, + permissions: [{ id: roleId, type: ApplicationCommandPermissionType.Role, permission: false }], +}); +``` ### Monetization diff --git a/packages/plugin-gateway/src/errors/Messages.ts b/packages/plugin-gateway/src/errors/Messages.ts index dcda5985..166acab6 100644 --- a/packages/plugin-gateway/src/errors/Messages.ts +++ b/packages/plugin-gateway/src/errors/Messages.ts @@ -93,6 +93,11 @@ export const GatewayErrorMessages = { NotGuildSoundboardSound: "Default soundboard sounds cannot be changed", SoundboardSoundContentType: "Could not infer the content type of the sound, pass contentType", + ApplicationCommandPermissionsTokenMissing: + "Editing the permissions of an application command needs a bearer token (with the applications.commands.permissions.update scope), but none was provided", + GlobalCommandPermissions: + "The permissions of an application command belong to a guild: provide a guild, or use the permissions manager of a guild", + MessageContentType: "The content of a message must be a string", MessageNonceLength: "A message nonce must be at most 25 characters long", MessageNonceType: "A message nonce must be an integer", diff --git a/packages/plugin-gateway/src/managers/ApplicationCommandManager.ts b/packages/plugin-gateway/src/managers/ApplicationCommandManager.ts new file mode 100644 index 00000000..9784bacf --- /dev/null +++ b/packages/plugin-gateway/src/managers/ApplicationCommandManager.ts @@ -0,0 +1,335 @@ +import { Collection } from "@discordjs/collection"; +import { makeURLSearchParams } from "@discordjs/rest"; +import { + Routes, + type APIApplicationCommand, + type Locale, + type RESTPatchAPIApplicationCommandJSONBody, + type RESTPostAPIApplicationCommandsJSONBody, +} from "discord-api-types/v10"; +import type { GatewayClient } from "../GatewayClient.js"; +import { GatewayTypeError } from "../errors/GatewayError.js"; +import { ApplicationCommand } from "../structures/applications/ApplicationCommand.js"; +import { bindClient } from "../structures/Structure.js"; +import type { ApplicationCommandDataResolvable, ApplicationCommandResolvable } from "../types.js"; +import { commandGuildId, compact, firstDefined, type Loose } from "../util/applicationCommands.js"; +import { PermissionsBitField } from "../util/PermissionsBitField.js"; +import { ApplicationCommandPermissionsManager } from "./ApplicationCommandPermissionsManager.js"; +import { BaseManager } from "./BaseManager.js"; + +/** + * The options to fetch application commands with. + */ +export interface FetchApplicationCommandOptions { + /** + * The ID of the command to fetch. Every command is fetched when omitted. + */ + id?: string; + /** + * The ID of the guild to fetch commands of, global commands when omitted. Ignored by the manager of a guild. + */ + guildId?: string; + /** + * The locale to localize the names and descriptions of every command for, sent as `X-Discord-Locale`. Not used + * when fetching one command. + */ + locale?: Locale | `${Locale}`; + /** + * Whether to include the full localization dictionaries, instead of the localized name and description. + */ + withLocalizations?: boolean; +} + +/** + * Manages the application commands of the application, global or of a guild, through the API. + * + * @remarks + * Commands are never cached: Discord sends no event when they change, so a copy could not be kept in sync. Every + * method calls the API, and returns new {@link ApplicationCommand} structures. + * + * `@wolfstar/http-framework` registers the commands of its pieces on its own, with the same bulk overwrite + * {@link ApplicationCommandManager.set} sends. Calling `set()` replaces whatever the framework registered, and + * nothing here runs by itself: use it to manage commands the framework does not, or instead of it. + */ +export class ApplicationCommandManager extends BaseManager { + /** + * The ID of the guild this manager acts on, `null` for global commands (and for the guilds of its methods' `guildId` + * option). + */ + public readonly guildId: string | null; + + /** + * The manager of the permissions of any command, in any guild (the one of this manager's guild). + */ + public readonly permissions: ApplicationCommandPermissionsManager; + + /** + * @param client The client. + * @param guildId The ID of the guild to bind the manager to, see `client.guilds.commands(guildId)` and + * `guild.commands`. Global when omitted. + */ + public constructor(client: GatewayClient, guildId: string | null = null) { + super(client); + this.guildId = guildId; + this.permissions = new ApplicationCommandPermissionsManager(client, { guildId }); + } + + /** + * The path of the commands, or of one command. + * + * @param options The ID of the command, and the ID of the guild (ignored by the manager of a guild). + */ + public commandPath({ id, guildId }: { id?: string; guildId?: string } = {}): `/${string}` { + const applicationId = this.client.application.id; + const guild = this.guildId ?? guildId; + if (guild) { + return id + ? Routes.applicationGuildCommand(applicationId, guild, id) + : Routes.applicationGuildCommands(applicationId, guild); + } + + return id + ? Routes.applicationCommand(applicationId, id) + : Routes.applicationCommands(applicationId); + } + + /** + * Resolves a command, or its ID, to its ID. + * + * @param command The command, a raw one, or its ID. + * @returns The ID, `null` if the value holds none. + */ + public resolveId(command: ApplicationCommandResolvable): string | null { + if (typeof command === "string") return command; + const id = (command as { id?: unknown } | null)?.id; + return typeof id === "string" ? id : null; + } + + /** + * Fetches one command, from the API. + * + * @param id The ID of the command, or the options with it. + */ + public fetch( + id: string | (FetchApplicationCommandOptions & { id: string }), + ): Promise; + /** + * Fetches every command, from the API. + * + * @param options The guild (ignored by the manager of a guild), the locale to localize for, and whether to include + * the localizations. + */ + public fetch( + options?: FetchApplicationCommandOptions & { id?: undefined }, + ): Promise>; + public async fetch( + options?: string | FetchApplicationCommandOptions, + ): Promise> { + if (typeof options === "string") return this.#fetchOne(options); + if (options?.id) return this.#fetchOne(options.id, options.guildId); + return this.#fetchMany(options); + } + + async #fetchOne(id: string, guildId?: string): Promise { + const applicationId = this.client.application.id; + const guild = this.guildId ?? guildId; + const data = guild + ? await this.client.api.applicationCommands.getGuildCommand(applicationId, guild, id) + : await this.client.api.applicationCommands.getGlobalCommand(applicationId, id); + return this.#build(data as APIApplicationCommand, guild); + } + + async #fetchMany({ + guildId, + locale, + withLocalizations, + }: FetchApplicationCommandOptions = {}): Promise> { + const applicationId = this.client.application.id; + const guild = this.guildId ?? guildId; + let commands: APIApplicationCommand[]; + if (locale) { + // The API of `@discordjs/core` takes no headers. + commands = (await this.client.rest.get(this.commandPath({ guildId }), { + headers: { "X-Discord-Locale": locale }, + query: makeURLSearchParams({ with_localizations: withLocalizations }), + })) as APIApplicationCommand[]; + } else { + const query = { with_localizations: withLocalizations }; + commands = (await (guild + ? this.client.api.applicationCommands.getGuildCommands(applicationId, guild, query) + : this.client.api.applicationCommands.getGlobalCommands( + applicationId, + query, + ))) as APIApplicationCommand[]; + } + + return this.#collect(commands, guild); + } + + /** + * Creates an application command. + * + * @param command The command: camel-cased data, a raw command, or a builder. + * @param guildId The ID of the guild to create it in, global when omitted. Ignored by the manager of a guild. + */ + public async create( + command: ApplicationCommandDataResolvable, + guildId?: string, + ): Promise { + const applicationId = this.client.application.id; + const guild = this.guildId ?? guildId; + const body = ApplicationCommandManager.transformCommand(command); + const data = guild + ? await this.client.api.applicationCommands.createGuildCommand( + applicationId, + guild, + body as never, + ) + : await this.client.api.applicationCommands.createGlobalCommand(applicationId, body); + return this.#build(data as APIApplicationCommand, guild); + } + + /** + * Replaces every command of the application, or of a guild, with the given ones: the bulk overwrite. + * + * @remarks + * It deletes the commands that are not among the given ones, including the ones `@wolfstar/http-framework` + * registered for its pieces. Use it to manage the commands yourself, not alongside the framework. + * + * @param commands The commands: camel-cased data, raw commands, or builders. + * @param guildId The ID of the guild to set them in, global when omitted. Ignored by the manager of a guild. + * @returns The commands as stored, by ID. + */ + public async set( + commands: readonly ApplicationCommandDataResolvable[], + guildId?: string, + ): Promise> { + const applicationId = this.client.application.id; + const guild = this.guildId ?? guildId; + const body = commands.map((command) => ApplicationCommandManager.transformCommand(command)); + const data = guild + ? await this.client.api.applicationCommands.bulkOverwriteGuildCommands( + applicationId, + guild, + body as never, + ) + : await this.client.api.applicationCommands.bulkOverwriteGlobalCommands(applicationId, body); + return this.#collect(data as APIApplicationCommand[], guild); + } + + /** + * Edits an application command. + * + * @param command The command, or its ID. + * @param data The fields to edit. + * @param guildId The ID of the guild it is registered in, global when omitted. Ignored by the manager of a guild. + * @throws {GatewayTypeError} `InvalidType` when `command` holds no ID. + */ + public async edit( + command: ApplicationCommandResolvable, + data: Partial, + guildId?: string, + ): Promise { + const id = this.resolveId(command); + if (!id) throw new GatewayTypeError("InvalidType", "command", "ApplicationCommandResolvable"); + + const applicationId = this.client.application.id; + const guild = this.guildId ?? guildId ?? commandGuildId(command); + const body = ApplicationCommandManager.transformCommand( + data as ApplicationCommandDataResolvable, + ) as RESTPatchAPIApplicationCommandJSONBody; + const patched = guild + ? await this.client.api.applicationCommands.editGuildCommand( + applicationId, + guild, + id, + body as never, + ) + : await this.client.api.applicationCommands.editGlobalCommand(applicationId, id, body); + return this.#build(patched as APIApplicationCommand, guild); + } + + /** + * Deletes an application command. + * + * @param command The command, or its ID. + * @param guildId The ID of the guild it is registered in, global when omitted. Ignored by the manager of a guild. + * @throws {GatewayTypeError} `InvalidType` when `command` holds no ID. + */ + public async delete(command: ApplicationCommandResolvable, guildId?: string): Promise { + const id = this.resolveId(command); + if (!id) throw new GatewayTypeError("InvalidType", "command", "ApplicationCommandResolvable"); + + const applicationId = this.client.application.id; + const guild = this.guildId ?? guildId ?? commandGuildId(command); + await (guild + ? this.client.api.applicationCommands.deleteGuildCommand(applicationId, guild, id) + : this.client.api.applicationCommands.deleteGlobalCommand(applicationId, id)); + } + + /** + * Transforms command data into the payload to send to the API, like discord.js's + * `ApplicationCommandManager.transformCommand`. + * + * @param command The command: camel-cased data, a raw command, or a builder (anything with a `toJSON()`). + */ + public static transformCommand( + command: ApplicationCommandDataResolvable, + ): RESTPostAPIApplicationCommandsJSONBody { + const data = command as Loose; + if (typeof data.toJSON === "function") { + return data.toJSON() as RESTPostAPIApplicationCommandsJSONBody; + } + + // `undefined` leaves the permissions as they are, `null` removes them. + let defaultMemberPermissions: string | null | undefined; + if ("default_member_permissions" in data) { + defaultMemberPermissions = data.default_member_permissions + ? BigInt(data.default_member_permissions).toString() + : data.default_member_permissions; + } + + if ("defaultMemberPermissions" in data) { + defaultMemberPermissions = + data.defaultMemberPermissions === null || data.defaultMemberPermissions === undefined + ? data.defaultMemberPermissions + : PermissionsBitField.resolve(data.defaultMemberPermissions).toString(); + } + + return compact({ + name: data.name, + name_localizations: firstDefined(data.nameLocalizations, data.name_localizations), + description: data.description, + nsfw: data.nsfw, + description_localizations: firstDefined( + data.descriptionLocalizations, + data.description_localizations, + ), + type: data.type, + options: data.options?.map((option: Loose) => + ApplicationCommand.transformOption(option as never), + ), + default_member_permissions: defaultMemberPermissions, + integration_types: data.integrationTypes ?? data.integration_types, + contexts: data.contexts, + handler: data.handler, + }) as RESTPostAPIApplicationCommandsJSONBody; + } + + // Builds a command, in the guild it was fetched from when the API does not say. + #build(data: APIApplicationCommand, guildId: string | null | undefined): ApplicationCommand { + return bindClient( + new ApplicationCommand(guildId && !data.guild_id ? { ...data, guild_id: guildId } : data), + this.client, + ); + } + + #collect( + commands: readonly APIApplicationCommand[], + guildId: string | null | undefined, + ): Collection { + const collection = new Collection(); + for (const data of commands) collection.set(data.id, this.#build(data, guildId)); + return collection; + } +} diff --git a/packages/plugin-gateway/src/managers/ApplicationCommandPermissionsManager.ts b/packages/plugin-gateway/src/managers/ApplicationCommandPermissionsManager.ts new file mode 100644 index 00000000..6ec44a19 --- /dev/null +++ b/packages/plugin-gateway/src/managers/ApplicationCommandPermissionsManager.ts @@ -0,0 +1,491 @@ +import { Collection } from "@discordjs/collection"; +import { applicationCommandPermissionsKey, type CacheEntityTypes } from "@wolfstar/plugin-cache"; +import { + ApplicationCommandPermissionType, + RESTJSONErrorCodes, + Routes, + type APIApplicationCommandPermission, + type APIGuildApplicationCommandPermissions, + type Snowflake, +} from "discord-api-types/v10"; +import { GatewayError, GatewayTypeError } from "../errors/GatewayError.js"; +import type { + ApplicationCommandPermissionIdResolvable, + ApplicationCommandResolvable, + GuildChannelResolvable, + GuildResolvable, + RoleResolvable, + UserResolvable, +} from "../types.js"; +import type { GatewayClient } from "../GatewayClient.js"; +import { BaseManager } from "./BaseManager.js"; + +/** + * A permission of an application command: whether a role, user, or channel may use it. + */ +export type ApplicationCommandPermissions = APIApplicationCommandPermission; + +/** + * The ID standing for the `@everyone` role in the permissions of a command: the ID of the guild. + */ +export type RolePermissionConstant = Snowflake; + +/** + * The ID standing for every channel in the permissions of a command: the ID of the guild minus one. + */ +export type ChannelPermissionConstant = Snowflake; + +/** + * Where an {@link ApplicationCommandPermissionsManager} acts: a guild and a command it is bound to, none of them for + * the manager of the application, whose options then have to carry them. + */ +export interface ApplicationCommandPermissionsScope { + guildId?: string | null; + commandId?: string | null; +} + +/** + * The options every method of the permissions manager takes to say where to act. + */ +export interface BaseApplicationCommandPermissionsOptions { + /** + * The guild to modify or check permissions in. Ignored by a manager bound to a guild. + */ + guild?: GuildResolvable; + /** + * The command to modify or check permissions of. Ignored by a manager bound to a command. + */ + command?: ApplicationCommandResolvable; +} + +/** + * The options to fetch permissions with. + */ +export interface FetchApplicationCommandPermissionsOptions extends BaseApplicationCommandPermissionsOptions { + /** + * Whether to skip the cache and always ask the API, for the permissions of one command. + * + * @default false + */ + force?: boolean; +} + +/** + * The options to set or add permissions with. + * + * @remarks + * Omitting `command` edits the permissions of the guild, as a whole, when the manager is not bound to a command: they + * apply to the commands without permissions of their own. + */ +export interface ApplicationCommandPermissionsEditOptions extends BaseApplicationCommandPermissionsOptions { + /** + * The permissions to set, or to add to the existing ones. + */ + permissions: ApplicationCommandPermissions[]; + /** + * The OAuth2 bearer token of a user able to manage the guild, with the `applications.commands.permissions.update` + * scope: Discord refuses the bot's own token here. + */ + token: string; +} + +/** + * The options to remove permissions with. At least one of `users`, `roles`, and `channels` is required. + */ +export interface RemoveApplicationCommandPermissionsOptions extends BaseApplicationCommandPermissionsOptions { + /** + * The OAuth2 bearer token of a user able to manage the guild, with the `applications.commands.permissions.update` + * scope. + */ + token: string; + users?: readonly UserResolvable[]; + /** + * The roles to remove: roles, or IDs, or the ID of the guild for `@everyone`. + */ + roles?: readonly (RolePermissionConstant | RoleResolvable)[]; + /** + * The channels to remove: channels, or IDs, or the ID of the guild minus one for every channel. + */ + channels?: readonly (ChannelPermissionConstant | GuildChannelResolvable)[]; +} + +/** + * The options to check for a permission with. + */ +export interface HasApplicationCommandPermissionsOptions extends BaseApplicationCommandPermissionsOptions { + /** + * The user, role, channel, or constant to look for. + */ + permissionId: ApplicationCommandPermissionIdResolvable; + /** + * Only look for a permission of this type, which tells apart a channel from the `@everyone` role when their IDs + * are equal. + */ + permissionType?: ApplicationCommandPermissionType; +} + +function isUnknownPermissions(error: unknown): boolean { + return ( + (error as { code?: unknown } | null)?.code === + RESTJSONErrorCodes.UnknownApplicationCommandPermissions + ); +} + +// Roles and channels are identified by their ID alone: unlike discord.js, nothing is resolved through a guild. +function idOf(value: unknown): string | null { + if (typeof value === "string") return value; + const id = (value as { id?: unknown } | null)?.id; + return typeof id === "string" ? id : null; +} + +function guildIdOf(guild: GuildResolvable | undefined): string | null { + if (guild === undefined || guild === null) return null; + if (typeof guild === "string") return guild; + const { guildId } = guild as { guildId?: unknown }; + return typeof guildId === "string" ? guildId : idOf(guild); +} + +/** + * Manages the permissions of application commands, like discord.js's `ApplicationCommandPermissionsManager`. + * + * @remarks + * Reachable from `client.application.commands.permissions` (any guild and command), `guild.commands.permissions` (one + * guild) and `command.permissions` (one command): the scope it is bound to decides which of the options' `guild` and + * `command` are needed, and are ignored. + * + * Reads use the bot's authorization, and the `applicationCommandPermissions` cache (kept up to date by + * `APPLICATION_COMMAND_PERMISSIONS_UPDATE`) when the client has one. Writes (`set`, `add`, `remove`) cannot use it: + * Discord only accepts an OAuth2 bearer token of a user there, passed as the `token` option. Obtaining one is up to + * the caller, there is no OAuth2 flow here. + */ +export class ApplicationCommandPermissionsManager extends BaseManager { + /** + * The ID of the guild this manager acts on, `null` for any. + */ + public readonly guildId: string | null; + + /** + * The ID of the command this manager acts on, `null` for any. + */ + public readonly commandId: string | null; + + public constructor(client: GatewayClient, scope: ApplicationCommandPermissionsScope = {}) { + super(client); + this.guildId = scope.guildId ?? null; + this.commandId = scope.commandId ?? null; + } + + /** + * The path of the permissions of a guild's commands, or of one command. + * + * @param guildId The ID of the guild. + * @param commandId The ID of the command. + */ + public permissionsPath(guildId: string, commandId?: string): string { + const applicationId = this.client.application.id; + return commandId + ? Routes.applicationCommandPermissions(applicationId, guildId, commandId) + : Routes.guildApplicationCommandsPermissions(applicationId, guildId); + } + + /** + * Fetches the permissions of one command, or of every command of a guild. The ID of the application as the command + * stands for the permissions of the guild as a whole. + * + * @remarks + * The permissions of one command come from the cache when it has them, unless `force` is set. The API's answer is + * written to the cache. + * + * @param options The guild and command, as far as this manager is not bound to them. + * @returns The permissions of the command, or of each command of the guild, by command ID. + * @throws {GatewayError} `GlobalCommandPermissions` when no guild is known. + */ + public fetch( + options: FetchApplicationCommandPermissionsOptions & { command: ApplicationCommandResolvable }, + ): Promise; + public fetch( + options?: FetchApplicationCommandPermissionsOptions, + ): Promise>; + public async fetch({ + guild, + command, + force = false, + }: FetchApplicationCommandPermissionsOptions = {}): Promise< + ApplicationCommandPermissions[] | Collection + > { + const { guildId, commandId } = this.#validate(guild, command); + const applicationId = this.client.application.id; + if (commandId) { + if (!force) { + const cached = await this.#cached(guildId, commandId); + if (cached) return cached.permissions; + } + + const data = await this.client.api.applicationCommands.getGuildCommandPermissions( + applicationId, + guildId, + commandId, + ); + await this.#remember(data); + return data.permissions; + } + + const all = await this.client.api.applicationCommands.getGuildCommandsPermissions( + applicationId, + guildId, + ); + await Promise.all(all.map((data) => this.#remember(data))); + return new Collection( + all.map((data): [string, ApplicationCommandPermissions[]] => [data.id, data.permissions]), + ); + } + + /** + * Sets the permissions of the guild, or of a command, replacing the existing ones. + * + * @param options The permissions, the bearer token, and the guild and command as far as this manager is not bound to + * them. + * @returns The permissions as stored. + * @throws {GatewayError} `ApplicationCommandPermissionsTokenMissing` without a token, and `GlobalCommandPermissions` + * when no guild is known. + */ + public async set( + options: ApplicationCommandPermissionsEditOptions, + ): Promise { + const { guild, command, permissions, token } = options; + if (!token) throw new GatewayError("ApplicationCommandPermissionsTokenMissing"); + + const { guildId, commandId } = this.#validate(guild, command, true); + if (!Array.isArray(permissions)) { + throw new GatewayTypeError( + "InvalidType", + "permissions", + "Array of ApplicationCommandPermissions", + true, + ); + } + + const data = await this.client.api.applicationCommands.editGuildCommandPermissions( + token, + this.client.application.id, + guildId, + commandId, + { permissions }, + ); + await this.#remember(data); + return data.permissions; + } + + /** + * Adds permissions to the existing ones of the guild or a command. A permission for an ID that has one already + * replaces it. + * + * @param options The permissions to add, the bearer token, and the guild and command as far as this manager is not + * bound to them. + * @returns The permissions as stored. + * @throws {GatewayError} `ApplicationCommandPermissionsTokenMissing` without a token, and `GlobalCommandPermissions` + * when no guild is known. + */ + public async add( + options: ApplicationCommandPermissionsEditOptions, + ): Promise { + const { guild, command, permissions, token } = options; + if (!token) throw new GatewayError("ApplicationCommandPermissionsTokenMissing"); + + const { guildId, commandId } = this.#validate(guild, command, true); + if (!Array.isArray(permissions)) { + throw new GatewayTypeError( + "InvalidType", + "permissions", + "Array of ApplicationCommandPermissions", + true, + ); + } + + const existing = await this.#existing(guildId, commandId, true); + const merged = [...permissions]; + for (const permission of existing) { + if (!merged.some((other) => other.id === permission.id)) merged.push(permission); + } + + return this.set({ guild: guildId, command: commandId, permissions: merged, token }); + } + + /** + * Removes users, roles, and channels from the permissions of the guild or a command. + * + * @param options Who to remove, the bearer token, and the guild and command as far as this manager is not bound to + * them. + * @returns The permissions as stored. + * @throws {GatewayError} `ApplicationCommandPermissionsTokenMissing` without a token, and `GlobalCommandPermissions` + * when no guild is known. + */ + public async remove( + options: RemoveApplicationCommandPermissionsOptions, + ): Promise { + const { guild, command, users, roles, channels, token } = options; + if (!token) throw new GatewayError("ApplicationCommandPermissionsTokenMissing"); + + const { guildId, commandId } = this.#validate(guild, command, true); + if (!users && !roles && !channels) { + throw new GatewayTypeError( + "InvalidType", + "users OR roles OR channels", + "Array or Resolvable", + true, + ); + } + + const userIds = this.#ids(users, "users", (user) => this.client.users.resolveId(user)); + const roleIds = this.#ids(roles, "roles", idOf); + const channelIds = this.#ids(channels, "channels", idOf); + + const existing = await this.#existing(guildId, commandId, true); + const permissions = existing.filter((permission) => { + switch (permission.type) { + case ApplicationCommandPermissionType.Role: + return !roleIds.includes(permission.id); + case ApplicationCommandPermissionType.User: + return !userIds.includes(permission.id); + case ApplicationCommandPermissionType.Channel: + return !channelIds.includes(permission.id); + default: + return true; + } + }); + + return this.set({ guild: guildId, command: commandId, permissions, token }); + } + + /** + * Checks whether a command has a permission for a user, role, channel, or constant. + * + * @remarks + * It reads the permissions like {@link ApplicationCommandPermissionsManager.fetch} does, from the cache when it has + * them. Pass `force: true` to {@link ApplicationCommandPermissionsManager.fetch} first for fresher ones. + * + * @param options The ID to look for, and the guild and command as far as this manager is not bound to them. + * @throws {GatewayError} `GlobalCommandPermissions` when no guild is known. + */ + public async has(options: HasApplicationCommandPermissionsOptions): Promise { + const { guild, command, permissionId, permissionType } = options; + const { guildId, commandId } = this.#validate(guild, command); + if (!commandId) { + throw new GatewayTypeError("InvalidType", "command", "ApplicationCommandResolvable"); + } + + // A user, a role, and a channel all resolve to their ID. + const resolved = + typeof permissionId === "string" + ? permissionId + : this.client.users.resolveId(permissionId as UserResolvable); + if (!resolved) { + throw new GatewayTypeError( + "InvalidType", + "permissionId", + "UserResolvable, RoleResolvable, ChannelResolvable, or Permission Constant", + ); + } + + const existing = await this.#existing(guildId, commandId); + // The type is checked when given, for the one case where a channel has the ID of the @everyone role. + return existing.some( + (permission) => + permission.id === resolved && (permissionType ?? permission.type) === permission.type, + ); + } + + // The permissions of a command, none when it has none of its own. + async #existing( + guildId: string, + commandId: string, + force = false, + ): Promise { + try { + return await this.fetch({ guild: guildId, command: commandId, force }); + } catch (error) { + if (isUnknownPermissions(error)) return []; + throw error; + } + } + + #ids( + values: readonly Value[] | undefined, + name: string, + resolve: (value: Value) => string | null, + ): string[] { + if (values === undefined) return []; + if (!Array.isArray(values)) throw new GatewayTypeError("InvalidType", name, "Array", true); + return values.map((value) => { + const id = resolve(value); + if (!id) throw new GatewayTypeError("InvalidElement", "Array", name, value); + return id; + }); + } + + // The guild and command to act on. With `defaultCommand`, the application's own ID stands for no command: the + // permissions of the guild as a whole. + #validate( + guild: GuildResolvable | undefined, + command: ApplicationCommandResolvable | undefined, + defaultCommand: true, + ): { guildId: string; commandId: string }; + #validate( + guild: GuildResolvable | undefined, + command: ApplicationCommandResolvable | undefined, + ): { guildId: string; commandId: string | null }; + #validate( + guild: GuildResolvable | undefined, + command: ApplicationCommandResolvable | undefined, + defaultCommand = false, + ): { guildId: string; commandId: string | null } { + const guildId = this.guildId ?? guildIdOf(guild); + if (!guildId) throw new GatewayError("GlobalCommandPermissions"); + + let commandId = this.commandId; + if (command && !commandId) { + commandId = idOf(command); + if (!commandId) { + throw new GatewayTypeError("InvalidType", "command", "ApplicationCommandResolvable", true); + } + } + + return { + guildId, + commandId: commandId ?? (defaultCommand ? this.client.application.id : null), + }; + } + + // Reads the cached permissions of a command, if the client caches them. + async #cached( + guildId: string, + commandId: string, + ): Promise { + const store = this.client.cache?.applicationCommandPermissions; + if (!store) return undefined; + + const key = applicationCommandPermissionsKey(this.client.application.id, guildId, commandId); + return this.client.guardCache( + "applicationCommandPermissions", + "get", + key, + () => store.get(key), + undefined, + ); + } + + // Keeps what the API answered in the cache, where the next permissions update merges into it. + async #remember(data: APIGuildApplicationCommandPermissions): Promise { + const store = this.client.cache?.applicationCommandPermissions; + if (!store) return; + + const key = applicationCommandPermissionsKey(data.application_id, data.guild_id, data.id); + await this.client.guardCache( + "applicationCommandPermissions", + "set", + key, + () => store.set(key, data), + undefined, + ); + } +} diff --git a/packages/plugin-gateway/src/managers/GuildApplicationCommandManager.ts b/packages/plugin-gateway/src/managers/GuildApplicationCommandManager.ts new file mode 100644 index 00000000..5bb1adf6 --- /dev/null +++ b/packages/plugin-gateway/src/managers/GuildApplicationCommandManager.ts @@ -0,0 +1,21 @@ +import type { GatewayClient } from "../GatewayClient.js"; +import { ApplicationCommandManager } from "./ApplicationCommandManager.js"; + +/** + * Manages the application commands of one guild, like discord.js's `GuildApplicationCommandManager`: an + * {@link ApplicationCommandManager} bound to the guild, whose `guildId` options are ignored. + * + * @remarks + * Reachable from `guild.commands` and `client.guilds.commands(guildId)`, a new instance on every access. + */ +export class GuildApplicationCommandManager extends ApplicationCommandManager { + declare public readonly guildId: string; + + /** + * @param client The client. + * @param guildId The ID of the guild. + */ + public constructor(client: GatewayClient, guildId: string) { + super(client, guildId); + } +} diff --git a/packages/plugin-gateway/src/managers/GuildManager.ts b/packages/plugin-gateway/src/managers/GuildManager.ts index c9c73c00..798eaa95 100644 --- a/packages/plugin-gateway/src/managers/GuildManager.ts +++ b/packages/plugin-gateway/src/managers/GuildManager.ts @@ -69,6 +69,7 @@ import { import { SystemChannelFlagsBitField } from "../util/flags.js"; import { CachedManager } from "./CachedManager.js"; import { AutoModerationRuleManager } from "./AutoModerationRuleManager.js"; +import { GuildApplicationCommandManager } from "./GuildApplicationCommandManager.js"; import { GuildBanManager } from "./GuildBanManager.js"; import { GuildChannelManager } from "./GuildChannelManager.js"; import { GuildIntegrationManager } from "./GuildIntegrationManager.js"; @@ -758,6 +759,15 @@ export class GuildManager extends CachedManager<"guilds", Guild, [guildId: strin return new GuildEmojiManager(this.client, guildId); } + /** + * Gets the manager of a guild's application commands. + * + * @param guildId The ID of the guild. + */ + public commands(guildId: string): GuildApplicationCommandManager { + return new GuildApplicationCommandManager(this.client, guildId); + } + /** * Gets the manager of a guild's custom stickers. * diff --git a/packages/plugin-gateway/src/managers/index.ts b/packages/plugin-gateway/src/managers/index.ts index 4ca78c20..5c3c4855 100644 --- a/packages/plugin-gateway/src/managers/index.ts +++ b/packages/plugin-gateway/src/managers/index.ts @@ -1,3 +1,5 @@ +export * from "./ApplicationCommandManager.js"; +export * from "./ApplicationCommandPermissionsManager.js"; export * from "./AutoModerationRuleManager.js"; export * from "./BaseManager.js"; export * from "./CachedManager.js"; @@ -7,6 +9,7 @@ export * from "./ChannelMessageManager.js"; export * from "./ChannelThreadManager.js"; export * from "./DataManager.js"; export * from "./EntitlementManager.js"; +export * from "./GuildApplicationCommandManager.js"; export * from "./GuildBanManager.js"; export * from "./GuildChannelManager.js"; export * from "./GuildEmojiManager.js"; diff --git a/packages/plugin-gateway/src/structures/applications/ApplicationCommand.ts b/packages/plugin-gateway/src/structures/applications/ApplicationCommand.ts new file mode 100644 index 00000000..a2349d98 --- /dev/null +++ b/packages/plugin-gateway/src/structures/applications/ApplicationCommand.ts @@ -0,0 +1,654 @@ +import { + ApplicationCommandOptionType, + type APIApplicationCommand, + type APIApplicationCommandOption, + type ApplicationCommandType, + type ApplicationIntegrationType, + type ChannelType, + type EntryPointCommandHandlerType, + type FileUploadType, + type InteractionContextType, + type LocalizationMap, +} from "discord-api-types/v10"; +import { ApplicationCommandPermissionsManager } from "../../managers/ApplicationCommandPermissionsManager.js"; +import type { ApplicationCommandManager } from "../../managers/ApplicationCommandManager.js"; +import { compact, firstDefined, type Loose } from "../../util/applicationCommands.js"; +import { cachedGuild } from "../../util/cache.js"; +import { isDeepEqual } from "../../util/equal.js"; +import { PermissionsBitField, type PermissionResolvable } from "../../util/PermissionsBitField.js"; +import type { Guild } from "../guilds/Guild.js"; +import { kData, kPatch, snowflakeTimestamp, Structure } from "../Structure.js"; + +/** + * A choice of an application command option, as the API returns it. + */ +export interface ApplicationCommandOptionChoice { + name: string; + /** + * The name in the locale the command was fetched with. + */ + nameLocalized?: string | null; + nameLocalizations?: LocalizationMap | null; + value: number | string; +} + +/** + * A choice to give an application command option. + */ +export interface ApplicationCommandOptionChoiceData { + name: string; + nameLocalizations?: LocalizationMap | null; + value: number | string; +} + +interface ApplicationCommandOptionBase { + type: ApplicationCommandOptionType; + name: string; + nameLocalizations?: LocalizationMap | null; + description: string; + descriptionLocalizations?: LocalizationMap | null; + /** + * Whether the option is required, `false` unless it is given, and not defined for subcommands (groups). + */ + required?: boolean; + /** + * Whether the option is autocompleted, for string, integer, and number options. + */ + autocomplete?: boolean; + choices?: Choice[]; + /** + * The types of channels that can be picked, for channel options. + */ + channelTypes?: ChannelType[]; + /** + * The types of files that can be uploaded, for attachment options. + */ + fileTypes?: FileUploadType[]; + minValue?: number; + maxValue?: number; + minLength?: number; + maxLength?: number; +} + +/** + * An option of an application command or subcommand, as the API returns it with camel-cased keys, like discord.js's + * `ApplicationCommandOption`. + */ +export interface ApplicationCommandOption extends ApplicationCommandOptionBase { + /** + * The name in the locale the command was fetched with. + */ + nameLocalized?: string; + /** + * The description in the locale the command was fetched with. + */ + descriptionLocalized?: string; + options?: ApplicationCommandOption[]; +} + +/** + * An option to give an application command or subcommand, like discord.js's `ApplicationCommandOptionData`. + */ +export interface ApplicationCommandOptionData extends ApplicationCommandOptionBase { + options?: readonly ApplicationCommandOptionResolvable[]; +} + +/** + * An option to give an application command: camel-cased data, or the raw API option a builder produces. + */ +export type ApplicationCommandOptionResolvable = + | APIApplicationCommandOption + | ApplicationCommandOptionData; + +/** + * The data to create or edit an application command with, like discord.js's `ApplicationCommandData`. + */ +export interface ApplicationCommandData { + /** + * The name of the command, in lowercase for chat input commands. + */ + name: string; + nameLocalizations?: LocalizationMap | null; + /** + * The description of the command, for chat input and primary entry point commands. + */ + description?: string; + descriptionLocalizations?: LocalizationMap | null; + /** + * Whether the command is age-restricted. + */ + nsfw?: boolean; + /** + * The type of the command. + * + * @default ApplicationCommandType.ChatInput + */ + type?: ApplicationCommandType; + options?: readonly ApplicationCommandOptionResolvable[]; + /** + * The permissions a member needs by default to run the command, `null` for everyone. + */ + defaultMemberPermissions?: PermissionResolvable | null; + /** + * The installation contexts where the command is available, for global commands. + */ + integrationTypes?: ApplicationIntegrationType[]; + /** + * The interaction contexts where the command can be used, for global commands. + */ + contexts?: InteractionContextType[]; + /** + * Whether the app or Discord handles the interaction, for primary entry point commands. + */ + handler?: EntryPointCommandHandlerType; +} + +function isObject(value: unknown): value is Loose { + return typeof value === "object" && value !== null; +} + +function transformChoice(choice: Loose, received: boolean): Loose { + return compact({ + name: choice.name, + [received ? "nameLocalized" : "name_localized"]: choice.nameLocalized ?? choice.name_localized, + [received ? "nameLocalizations" : "name_localizations"]: firstDefined( + choice.nameLocalizations, + choice.name_localizations, + ), + value: choice.value, + }); +} + +function optionEquals(existing: Loose, option: Loose, enforceOptionOrder: boolean): boolean { + if ( + option.name !== existing.name || + option.type !== existing.type || + option.description !== existing.description || + option.autocomplete !== existing.autocomplete || + (option.required ?? + (option.type === ApplicationCommandOptionType.Subcommand || + option.type === ApplicationCommandOptionType.SubcommandGroup + ? undefined + : false)) !== existing.required || + option.choices?.length !== existing.choices?.length || + option.options?.length !== existing.options?.length || + (option.channelTypes ?? option.channel_types)?.length !== existing.channelTypes?.length || + (option.fileTypes ?? option.file_types)?.length !== existing.fileTypes?.length || + (option.minValue ?? option.min_value) !== existing.minValue || + (option.maxValue ?? option.max_value) !== existing.maxValue || + (option.minLength ?? option.min_length) !== existing.minLength || + (option.maxLength ?? option.max_length) !== existing.maxLength || + !isDeepEqual( + option.nameLocalizations ?? option.name_localizations ?? {}, + existing.nameLocalizations ?? {}, + ) || + !isDeepEqual( + option.descriptionLocalizations ?? option.description_localizations ?? {}, + existing.descriptionLocalizations ?? {}, + ) + ) { + return false; + } + + if (existing.choices) { + if (enforceOptionOrder) { + const same = (existing.choices as Loose[]).every((choice, index) => { + const other = option.choices[index] as Loose | undefined; + return ( + isObject(other) && + choice.name === other.name && + choice.value === other.value && + isDeepEqual( + choice.nameLocalizations ?? {}, + other.nameLocalizations ?? other.name_localizations ?? {}, + ) + ); + }); + if (!same) return false; + } else { + const newChoices = new Map( + (option.choices as Loose[]).filter(isObject).map((choice) => [choice.name, choice]), + ); + for (const choice of existing.choices as Loose[]) { + const found = newChoices.get(choice.name); + if (!found || found.value !== choice.value) return false; + } + } + } + + if (existing.channelTypes) { + const types = (option.channelTypes ?? option.channel_types) as unknown[]; + for (const type of existing.channelTypes) if (!types.includes(type)) return false; + } + + if (existing.fileTypes) { + const types = (option.fileTypes ?? option.file_types) as unknown[]; + for (const type of existing.fileTypes) if (!types.includes(type)) return false; + } + + if (existing.options) { + return ApplicationCommand.optionsEqual(existing.options, option.options, enforceOptionOrder); + } + + return true; +} + +/** + * A command of the application, registered globally or in one guild, like discord.js's `ApplicationCommand`. + * + * @remarks + * Application commands are only fetched and edited through the API, never cached: nothing keeps a copy in sync, as + * Discord sends no event when they change. `edit()` patches this very structure with the API's answer. + */ +export class ApplicationCommand extends Structure { + // The options are transformed once per payload, so that they stay the same objects between reads. + #options: { source: unknown; value: ApplicationCommandOption[] | null } | null = null; + + #permissions: ApplicationCommandPermissionsManager | null = null; + + public get id(): string { + return this[kData].id; + } + + /** + * The ID of the application owning the command. + */ + public get applicationId(): string { + return this[kData].application_id; + } + + /** + * The ID of the guild the command is registered in, `null` for a global command. + */ + public get guildId(): string | null { + return this[kData].guild_id ?? null; + } + + /** + * The guild the command is registered in, from the cache. `null` for a global command, or when the guild is not + * cached: use {@link ApplicationCommand.fetchGuild} to always get it. + */ + public get guild(): Guild | null { + return this.lazyRelation("guild", (client) => cachedGuild(client, this.guildId)); + } + + /** + * Fetches the guild the command is registered in, cache first. + * + * @returns The guild, `null` for a global command. + */ + public async fetchGuild(): Promise { + const { guildId } = this; + return guildId ? this.client.guilds.fetch(guildId) : null; + } + + /** + * The manager of this command's permissions: on its guild, or on any guild when the command is global. + */ + public get permissions(): ApplicationCommandPermissionsManager { + return (this.#permissions ??= new ApplicationCommandPermissionsManager(this.client, { + guildId: this.guildId, + commandId: this.id, + })); + } + + /** + * The manager this command belongs to: the one of its guild, else the application's. + */ + public get manager(): ApplicationCommandManager { + const { guildId } = this; + return guildId ? this.client.guilds.commands(guildId) : this.client.application.commands; + } + + public get type(): ApplicationCommandType { + return this[kData].type; + } + + public get name(): string { + return this[kData].name; + } + + public get nameLocalizations(): LocalizationMap | null { + return this[kData].name_localizations ?? null; + } + + /** + * The name in the locale the command was fetched with, `null` if it was not fetched with one. + */ + public get nameLocalized(): string | null { + return this[kData].name_localized ?? null; + } + + public get description(): string { + return this[kData].description; + } + + public get descriptionLocalizations(): LocalizationMap | null { + return this[kData].description_localizations ?? null; + } + + /** + * The description in the locale the command was fetched with, `null` if it was not fetched with one. + */ + public get descriptionLocalized(): string | null { + return this[kData].description_localized ?? null; + } + + /** + * Whether the command is age-restricted. + */ + public get nsfw(): boolean { + return this[kData].nsfw ?? false; + } + + /** + * The options of the command, `null` if it has none. + */ + public get options(): ApplicationCommandOption[] | null { + const source = this[kData].options; + let cached = this.#options; + if (cached?.source !== source || cached === null) { + cached = { + source, + value: source + ? source.map((option) => ApplicationCommand.transformOption(option, true)) + : null, + }; + this.#options = cached; + } + + return cached.value; + } + + /** + * The permissions a member needs by default to run the command, `null` for everyone. + */ + public get defaultMemberPermissions(): Readonly | null { + const permissions = this[kData].default_member_permissions; + return permissions ? new PermissionsBitField(BigInt(permissions)).freeze() : null; + } + + /** + * The installation contexts where the command is available, only for global commands. + */ + public get integrationTypes(): ApplicationIntegrationType[] | null { + return this[kData].integration_types ?? null; + } + + /** + * The interaction contexts where the command can be used, only for global commands. + */ + public get contexts(): InteractionContextType[] | null { + return this[kData].contexts ?? null; + } + + /** + * Whether the app or Discord handles the interaction, only for primary entry point commands. + */ + public get handler(): EntryPointCommandHandlerType | null { + return this[kData].handler ?? null; + } + + /** + * The autoincrementing version of the command, updated on substantial changes. + */ + public get version(): string { + return this[kData].version; + } + + public get createdTimestamp(): number { + return snowflakeTimestamp(this.id); + } + + public get createdAt(): Date { + return new Date(this.createdTimestamp); + } + + /** + * Edits the command, and patches this structure with the result. + * + * @param data The fields to edit. + * @returns This command. + */ + public async edit(data: Partial): Promise { + const edited = await this.manager.edit(this, data); + // The API leaves out what a command no longer has, which a patch would otherwise keep. + return this[kPatch]({ + name_localizations: undefined, + name_localized: undefined, + description_localizations: undefined, + description_localized: undefined, + options: undefined, + integration_types: undefined, + contexts: undefined, + handler: undefined, + ...edited.toJSON(), + }); + } + + /** + * Edits the name of the command. + * + * @param name The new name. + */ + public setName(name: string): Promise { + return this.edit({ name }); + } + + /** + * Edits the localized names of the command. + * + * @param nameLocalizations The new localized names, `null` removes them. + */ + public setNameLocalizations(nameLocalizations: LocalizationMap | null): Promise { + return this.edit({ nameLocalizations }); + } + + /** + * Edits the description of the command. + * + * @param description The new description. + */ + public setDescription(description: string): Promise { + return this.edit({ description }); + } + + /** + * Edits the localized descriptions of the command. + * + * @param descriptionLocalizations The new localized descriptions, `null` removes them. + */ + public setDescriptionLocalizations( + descriptionLocalizations: LocalizationMap | null, + ): Promise { + return this.edit({ descriptionLocalizations }); + } + + /** + * Edits the permissions a member needs by default to run the command. + * + * @param defaultMemberPermissions The new permissions, `null` for everyone. + */ + public setDefaultMemberPermissions( + defaultMemberPermissions: PermissionResolvable | null, + ): Promise { + return this.edit({ defaultMemberPermissions }); + } + + /** + * Edits the options of the command. + * + * @param options The new options. + */ + public setOptions(options: readonly ApplicationCommandOptionResolvable[]): Promise { + return this.edit({ options }); + } + + /** + * Deletes the command. + * + * @returns This command. + */ + public async delete(): Promise { + await this.manager.delete(this); + return this; + } + + /** + * Whether this command equals another one, like discord.js's `ApplicationCommand#equals`. It compares every field + * the other command has, so for most uses comparing IDs is faster and enough. `false` for anything that is not a + * command, data, or raw command. + * + * @param command The command, its data, or its raw form, to compare with. + * @param enforceOptionOrder Whether options and choices must come in the same order. Discord may not keep the + * order they were sent in. + */ + public equals(command: unknown, enforceOptionOrder = false): boolean { + if (!isObject(command)) return false; + if (command.id && this.id !== command.id) return false; + + let defaultMemberPermissions: bigint | null = null; + try { + if ("default_member_permissions" in command) { + defaultMemberPermissions = command.default_member_permissions + ? BigInt(command.default_member_permissions) + : null; + } + + if ("defaultMemberPermissions" in command) { + defaultMemberPermissions = + command.defaultMemberPermissions === null || + command.defaultMemberPermissions === undefined + ? null + : PermissionsBitField.resolve(command.defaultMemberPermissions); + } + } catch { + return false; + } + + if ( + command.name !== this.name || + ("description" in command && command.description !== this.description) || + ("version" in command && command.version !== this.version) || + (command.type && command.type !== this.type) || + ("nsfw" in command && command.nsfw !== this.nsfw) || + command.options?.length !== this.options?.length || + defaultMemberPermissions !== (this.defaultMemberPermissions?.bitField ?? null) || + !isDeepEqual( + command.nameLocalizations ?? command.name_localizations ?? {}, + this.nameLocalizations ?? {}, + ) || + !isDeepEqual( + command.descriptionLocalizations ?? command.description_localizations ?? {}, + this.descriptionLocalizations ?? {}, + ) || + !isDeepEqual( + command.integrationTypes ?? command.integration_types ?? [], + this.integrationTypes ?? [], + ) || + !isDeepEqual(command.contexts ?? [], this.contexts ?? []) || + ("handler" in command && command.handler !== this.handler) + ) { + return false; + } + + // Both lengths are equal by now, so one of them being defined is enough. + if (command.options) { + return ApplicationCommand.optionsEqual(this.options!, command.options, enforceOptionOrder); + } + + return true; + } + + /** + * Recursively checks that the options of an application command equal the given ones, like discord.js's + * `ApplicationCommand.optionsEqual`. For most uses, {@link ApplicationCommand.equals} is better. `false` for + * anything that is not a list of options. + * + * @param existing The options of the existing command, {@link ApplicationCommand.options}. + * @param options The options to compare with: camel-cased data or raw options. + * @param enforceOptionOrder Whether options and choices must come in the same order. + */ + public static optionsEqual( + existing: readonly ApplicationCommandOption[], + options: readonly ApplicationCommandOptionResolvable[], + enforceOptionOrder = false, + ): boolean { + if (!Array.isArray(existing) || !Array.isArray(options)) return false; + if (existing.length !== options.length) return false; + if (!existing.every(isObject) || !options.every(isObject)) return false; + + if (enforceOptionOrder) { + return existing.every((option, index) => + optionEquals(option, options[index] as Loose, enforceOptionOrder), + ); + } + + const newOptions = new Map( + options.map((option) => [(option as Loose).name, option as Loose]), + ); + for (const option of existing) { + const found = newOptions.get(option.name); + if (!found || !optionEquals(option, found, false)) return false; + } + + return true; + } + + /** + * Transforms an option into the camel-cased form {@link ApplicationCommand.options} has, or into the API's, like + * discord.js's `ApplicationCommand.transformOption`. + * + * @param option The option, camel-cased or raw. + * @param received Whether to build the camel-cased form of an option received from Discord, rather than the raw one + * to send. + */ + public static transformOption( + option: ApplicationCommandOption | ApplicationCommandOptionResolvable, + received: true, + ): ApplicationCommandOption; + public static transformOption( + option: ApplicationCommandOption | ApplicationCommandOptionResolvable, + received?: false, + ): APIApplicationCommandOption; + public static transformOption( + option: ApplicationCommandOption | ApplicationCommandOptionResolvable, + received = false, + ): APIApplicationCommandOption | ApplicationCommandOption { + const data = option as Loose; + const key = (camel: string, snake: string) => (received ? camel : snake); + return compact({ + type: data.type, + name: data.name, + [key("nameLocalizations", "name_localizations")]: firstDefined( + data.nameLocalizations, + data.name_localizations, + ), + [key("nameLocalized", "name_localized")]: data.nameLocalized ?? data.name_localized, + description: data.description, + [key("descriptionLocalizations", "description_localizations")]: firstDefined( + data.descriptionLocalizations, + data.description_localizations, + ), + [key("descriptionLocalized", "description_localized")]: + data.descriptionLocalized ?? data.description_localized, + required: + data.required ?? + (data.type === ApplicationCommandOptionType.Subcommand || + data.type === ApplicationCommandOptionType.SubcommandGroup + ? undefined + : false), + autocomplete: data.autocomplete, + choices: data.choices?.map((choice: Loose) => transformChoice(choice, received)), + options: data.options?.map((nested: Loose) => + ApplicationCommand.transformOption(nested as never, received as never), + ), + [key("channelTypes", "channel_types")]: data.channelTypes ?? data.channel_types, + [key("fileTypes", "file_types")]: data.fileTypes ?? data.file_types, + [key("minValue", "min_value")]: data.minValue ?? data.min_value, + [key("maxValue", "max_value")]: data.maxValue ?? data.max_value, + [key("minLength", "min_length")]: data.minLength ?? data.min_length, + [key("maxLength", "max_length")]: data.maxLength ?? data.max_length, + }) as APIApplicationCommandOption | ApplicationCommandOption; + } +} diff --git a/packages/plugin-gateway/src/structures/applications/ClientApplication.ts b/packages/plugin-gateway/src/structures/applications/ClientApplication.ts index e24827ac..fbe10ef6 100644 --- a/packages/plugin-gateway/src/structures/applications/ClientApplication.ts +++ b/packages/plugin-gateway/src/structures/applications/ClientApplication.ts @@ -11,6 +11,7 @@ import type { RESTPatchCurrentApplicationJSONBody, } from "discord-api-types/v10"; import { GatewayTypeError } from "../../errors/GatewayError.js"; +import { ApplicationCommandManager } from "../../managers/ApplicationCommandManager.js"; import { EntitlementManager } from "../../managers/EntitlementManager.js"; import { SubscriptionManager } from "../../managers/SubscriptionManager.js"; import type { SKUResolvable } from "../../types.js"; @@ -129,6 +130,8 @@ function toInstallParams( * before relying on the other fields. */ export class ClientApplication extends Application { + #commands: ApplicationCommandManager | null = null; + // The owner is built once per payload, so that it stays the same object between reads. #owner: { team: unknown; user: unknown; value: Team | User | null } | null = null; @@ -142,6 +145,14 @@ export class ClientApplication extends Application { return this[kData].name === undefined; } + /** + * The global application commands, and the commands of any guild through their `guildId` option. Fetch-only: see + * {@link ApplicationCommandManager}. + */ + public get commands(): ApplicationCommandManager { + return (this.#commands ??= new ApplicationCommandManager(this.client)); + } + /** * The flags of the application. */ diff --git a/packages/plugin-gateway/src/structures/applications/index.ts b/packages/plugin-gateway/src/structures/applications/index.ts index 9c71573d..0641cc5e 100644 --- a/packages/plugin-gateway/src/structures/applications/index.ts +++ b/packages/plugin-gateway/src/structures/applications/index.ts @@ -1,4 +1,5 @@ export * from "./Application.js"; +export * from "./ApplicationCommand.js"; export * from "./ClientApplication.js"; export * from "./Team.js"; export * from "./TeamMember.js"; diff --git a/packages/plugin-gateway/src/structures/guilds/Guild.ts b/packages/plugin-gateway/src/structures/guilds/Guild.ts index 68131d99..e042fce5 100644 --- a/packages/plugin-gateway/src/structures/guilds/Guild.ts +++ b/packages/plugin-gateway/src/structures/guilds/Guild.ts @@ -41,6 +41,7 @@ import type { GuildInviteManager } from "../../managers/GuildInviteManager.js"; import type { PresenceManager } from "../../managers/PresenceManager.js"; import type { RoleManager } from "../../managers/RoleManager.js"; import type { VoiceStateManager } from "../../managers/VoiceStateManager.js"; +import type { GuildApplicationCommandManager } from "../../managers/GuildApplicationCommandManager.js"; import type { GuildStickerManager } from "../../managers/GuildStickerManager.js"; import { isPromiseLike } from "../../util/cache.js"; import { cdn } from "../../util/cdn.js"; @@ -422,6 +423,13 @@ export class Guild extends AnonymousGuild { return this.client.guilds.emojis(this.id); } + /** + * The application commands of the guild. Fetch-only: see `ApplicationCommandManager`. + */ + public get commands(): GuildApplicationCommandManager { + return this.client.guilds.commands(this.id); + } + /** * The custom stickers of the guild. */ diff --git a/packages/plugin-gateway/src/types.ts b/packages/plugin-gateway/src/types.ts index 6d2d386c..aad335f5 100644 --- a/packages/plugin-gateway/src/types.ts +++ b/packages/plugin-gateway/src/types.ts @@ -42,6 +42,10 @@ import type { Entitlement } from "./structures/monetization/Entitlement.js"; import type { SKU } from "./structures/monetization/SKU.js"; import type { Subscription } from "./structures/monetization/Subscription.js"; import type { Activity } from "./structures/presences/Activity.js"; +import type { + ApplicationCommand, + ApplicationCommandData, +} from "./structures/applications/ApplicationCommand.js"; import type { Presence } from "./structures/presences/Presence.js"; import type { SoundboardSound } from "./structures/soundboards/SoundboardSound.js"; import type { StageInstance } from "./structures/stageInstances/StageInstance.js"; @@ -359,14 +363,15 @@ export type MessageActionRowComponentResolvable = export type ApplicationResolvable = Activity | APIApplication | Snowflake; /** - * An application command, or its ID. + * An application command, a raw one, or its ID. */ -export type ApplicationCommandResolvable = APIApplicationCommand | Snowflake; +export type ApplicationCommandResolvable = APIApplicationCommand | ApplicationCommand | Snowflake; /** - * The data to create an application command with, raw or serializable (a builder). + * The data to create an application command with: camel-cased data, or raw or serializable (a builder). */ export type ApplicationCommandDataResolvable = + | ApplicationCommandData | JSONEncodable | RESTPostAPIApplicationCommandsJSONBody; diff --git a/packages/plugin-gateway/src/util/applicationCommands.ts b/packages/plugin-gateway/src/util/applicationCommands.ts new file mode 100644 index 00000000..258c60ee --- /dev/null +++ b/packages/plugin-gateway/src/util/applicationCommands.ts @@ -0,0 +1,26 @@ +// The loosely typed objects the application command comparisons and transformations read: raw payloads, camel-cased +// data, structures, or whatever the caller passed. +export type Loose = Record; + +// `null` is a value here, it removes localizations: unlike `??`, only `undefined` falls back to the raw key. +export function firstDefined( + camel: Value | undefined, + snake: Value | undefined, +): Value | undefined { + return camel === undefined ? snake : camel; +} + +export function compact(value: Value): Value { + for (const key of Object.keys(value)) if (value[key] === undefined) delete value[key]; + return value; +} + +/** + * Reads the guild a command is registered in from a resolvable: the `guildId` of an `ApplicationCommand`, or the + * `guild_id` of a raw command. + */ +export function commandGuildId(command: unknown): string | undefined { + if (typeof command !== "object" || command === null) return undefined; + const { guildId, guild_id } = command as { guildId?: string | null; guild_id?: string | null }; + return guildId ?? guild_id ?? undefined; +} diff --git a/packages/plugin-gateway/tests/application-commands.test.ts b/packages/plugin-gateway/tests/application-commands.test.ts new file mode 100644 index 00000000..97c190fe --- /dev/null +++ b/packages/plugin-gateway/tests/application-commands.test.ts @@ -0,0 +1,1107 @@ +import { Collection } from "@discordjs/collection"; +import { DiscordAPIError } from "@discordjs/rest"; +import { container } from "@wolfstar/http-framework"; +import { applicationCommandPermissionsKey, createInMemoryCache } from "@wolfstar/plugin-cache"; +import { + ApplicationCommandOptionType, + ApplicationCommandPermissionType, + ApplicationCommandType, + ApplicationIntegrationType, + ChannelType, + GatewayDispatchEvents, + GatewayIntentBits, + GatewayOpcodes, + InteractionContextType, + Locale, + PermissionFlagsBits, + Routes, + type APIApplicationCommand, + type APIApplicationCommandOption, + type APIGuildApplicationCommandPermissions, + type GatewayDispatchPayload, +} from "discord-api-types/v10"; +import { WebSocketShardEvents } from "@discordjs/ws"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + ApplicationCommand, + ApplicationCommandManager, + ApplicationCommandPermissionsManager, + GatewayClient, + GatewayError, + GatewayErrorCodes, + GatewayTypeError, + GuildApplicationCommandManager, + PermissionsBitField, + type ApplicationCommandData, +} from "../src/index.js"; + +const clientId = "266624760782258186"; +const guildId = "100000000000000010"; +const otherGuild = "100000000000000011"; +const commandId = "400000000000000040"; +const roleId = "300000000000000030"; +const userId = "600000000000000600"; +const channelId = "200000000000000020"; + +function createClient() { + return new GatewayClient({ + discordPublicKey: "0".repeat(64), + discordToken: "test-token", + clientId, + intents: GatewayIntentBits.Guilds, + cache: createInMemoryCache(), + }); +} + +const options: APIApplicationCommandOption[] = [ + { + type: ApplicationCommandOptionType.String, + name: "color", + description: "Pick one", + required: true, + choices: [ + { name: "Red", value: "red", name_localizations: { fr: "Rouge" } }, + { name: "Blue", value: "blue" }, + ], + min_length: 1, + max_length: 10, + }, + { + type: ApplicationCommandOptionType.Channel, + name: "where", + description: "Where", + channel_types: [ChannelType.GuildText, ChannelType.GuildVoice], + }, + { + type: ApplicationCommandOptionType.Subcommand, + name: "nested", + description: "A subcommand", + options: [{ type: ApplicationCommandOptionType.Integer, name: "n", description: "A number" }], + }, +]; + +function command(extra: Partial = {}): APIApplicationCommand { + return { + id: commandId, + application_id: clientId, + type: ApplicationCommandType.ChatInput, + name: "awoo", + description: "Howl", + name_localizations: { fr: "hurler" }, + description_localizations: { fr: "Hurle" }, + options, + default_member_permissions: "8", + version: "500000000000000050", + nsfw: false, + integration_types: [ApplicationIntegrationType.GuildInstall], + contexts: [InteractionContextType.Guild], + ...extra, + } as APIApplicationCommand; +} + +function permissionsOf( + extra: Partial = {}, +): APIGuildApplicationCommandPermissions { + return { + id: commandId, + application_id: clientId, + guild_id: guildId, + permissions: [ + { id: roleId, type: ApplicationCommandPermissionType.Role, permission: false }, + { id: userId, type: ApplicationCommandPermissionType.User, permission: true }, + { id: channelId, type: ApplicationCommandPermissionType.Channel, permission: true }, + ], + ...extra, + }; +} + +function unknownPermissions() { + return new DiscordAPIError( + { code: 10_066, message: "Unknown application command permissions" }, + 10_066, + 404, + "GET", + "/permissions", + {}, + ); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("ApplicationCommand", () => { + test("GIVEN a raw command THEN it exposes the fields with discord.js's shape", () => { + const built = new ApplicationCommand(command({ guild_id: guildId, nsfw: true })); + expect(built.id).toBe(commandId); + expect(built.applicationId).toBe(clientId); + expect(built.guildId).toBe(guildId); + expect(built.type).toBe(ApplicationCommandType.ChatInput); + expect(built.name).toBe("awoo"); + expect(built.nameLocalizations).toEqual({ fr: "hurler" }); + expect(built.nameLocalized).toBeNull(); + expect(built.description).toBe("Howl"); + expect(built.descriptionLocalizations).toEqual({ fr: "Hurle" }); + expect(built.descriptionLocalized).toBeNull(); + expect(built.nsfw).toBe(true); + expect(built.defaultMemberPermissions).toBeInstanceOf(PermissionsBitField); + expect(built.defaultMemberPermissions?.has("Administrator")).toBe(true); + expect(built.integrationTypes).toEqual([ApplicationIntegrationType.GuildInstall]); + expect(built.contexts).toEqual([InteractionContextType.Guild]); + expect(built.handler).toBeNull(); + expect(built.version).toBe("500000000000000050"); + expect(built.createdTimestamp).toBe(Number((BigInt(commandId) >> 22n) + 1_420_070_400_000n)); + expect(built.createdAt).toEqual(new Date(built.createdTimestamp)); + expect(built.toJSON().id).toBe(commandId); + }); + + test("GIVEN options THEN they are camel-cased once per payload", () => { + const built = new ApplicationCommand(command()); + + expect(built.options).toBe(built.options); + expect(built.options?.[0]).toEqual({ + type: ApplicationCommandOptionType.String, + name: "color", + description: "Pick one", + required: true, + choices: [ + { name: "Red", value: "red", nameLocalizations: { fr: "Rouge" } }, + { name: "Blue", value: "blue" }, + ], + minLength: 1, + maxLength: 10, + }); + expect(built.options?.[1]?.channelTypes).toEqual([ + ChannelType.GuildText, + ChannelType.GuildVoice, + ]); + expect(built.options?.[1]?.required).toBe(false); + expect(built.options?.[2]?.required).toBeUndefined(); + expect(built.options?.[2]?.options?.[0]?.name).toBe("n"); + expect(new ApplicationCommand(command({ options: undefined })).options).toBeNull(); + expect( + new ApplicationCommand(command({ default_member_permissions: null })) + .defaultMemberPermissions, + ).toBeNull(); + }); + + test("GIVEN a global or guild command THEN the manager and guild follow", async () => { + const client = createClient(); + const global = client.application.commands; + vi.spyOn(container.rest, "get").mockResolvedValue(command()); + + const fetched = await global.fetch(commandId); + expect(fetched.guildId).toBeNull(); + expect(fetched.manager).toBe(client.application.commands); + expect(fetched.client).toBe(client); + expect(await fetched.fetchGuild()).toBeNull(); + expect(fetched.guild).toBeNull(); + + const inGuild = await client.guilds.commands(guildId).fetch(commandId); + expect(inGuild.guildId).toBe(guildId); + expect(inGuild.manager).toBeInstanceOf(GuildApplicationCommandManager); + expect(inGuild.manager.guildId).toBe(guildId); + }); + + test("GIVEN edit THEN it patches this command, dropping what the API no longer returns", async () => { + const client = createClient(); + const get = vi.spyOn(container.rest, "get").mockResolvedValue(command()); + const patch = vi + .spyOn(container.rest, "patch") + .mockResolvedValue( + command({ name: "howl", options: undefined, name_localizations: undefined }), + ); + const fetched = await client.application.commands.fetch(commandId); + expect(get).toHaveBeenCalledTimes(1); + expect(fetched.options).not.toBeNull(); + + const edited = await fetched.edit({ name: "howl", options: [], nameLocalizations: null }); + + expect(patch).toHaveBeenCalledWith(Routes.applicationCommand(clientId, commandId), { + body: { name: "howl", options: [], name_localizations: null }, + signal: undefined, + }); + expect(edited).toBe(fetched); + expect(fetched.name).toBe("howl"); + expect(fetched.options).toBeNull(); + expect(fetched.nameLocalizations).toBeNull(); + expect(fetched.description).toBe("Howl"); + }); + + test("GIVEN the set helpers THEN each edits one field", async () => { + const client = createClient(); + const patch = vi.spyOn(container.rest, "patch").mockResolvedValue(command()); + const built = new ApplicationCommand(command({ guild_id: guildId })); + const bound = client.guilds.commands(guildId); + vi.spyOn(client.guilds, "commands").mockReturnValue(bound); + + await built.setName("a"); + await built.setNameLocalizations({ fr: "b" }); + await built.setDescription("c"); + await built.setDescriptionLocalizations({ fr: "d" }); + await built.setDefaultMemberPermissions(["ManageGuild"]); + await built.setDefaultMemberPermissions(null); + await built.setOptions([ + { type: ApplicationCommandOptionType.Boolean, name: "on", description: "On" }, + ]); + + const bodies = patch.mock.calls.map(([, init]) => (init as { body: unknown }).body); + expect(patch.mock.calls[0]?.[0]).toBe( + Routes.applicationGuildCommand(clientId, guildId, commandId), + ); + expect(bodies).toEqual([ + { name: "a" }, + { name_localizations: { fr: "b" } }, + { description: "c" }, + { description_localizations: { fr: "d" } }, + { default_member_permissions: PermissionFlagsBits.ManageGuild.toString() }, + { default_member_permissions: null }, + { + options: [ + { + type: ApplicationCommandOptionType.Boolean, + name: "on", + description: "On", + required: false, + }, + ], + }, + ]); + }); + + test("GIVEN delete THEN it removes the command and resolves to it", async () => { + const client = createClient(); + const del = vi.spyOn(container.rest, "delete").mockResolvedValue(undefined); + const global = new ApplicationCommand(command()); + const local = new ApplicationCommand(command({ guild_id: guildId })); + const bound = client.guilds.commands(guildId); + vi.spyOn(client.guilds, "commands").mockReturnValue(bound); + + expect(await global.delete()).toBe(global); + expect(await local.delete()).toBe(local); + + expect(del).toHaveBeenNthCalledWith(1, Routes.applicationCommand(clientId, commandId), { + signal: undefined, + }); + expect(del).toHaveBeenNthCalledWith( + 2, + Routes.applicationGuildCommand(clientId, guildId, commandId), + { signal: undefined }, + ); + }); + + test("GIVEN permissions THEN they are bound to the command and its guild", () => { + const client = createClient(); + const global = new ApplicationCommand(command()); + const local = new ApplicationCommand(command({ guild_id: guildId })); + + expect(global.permissions).toBeInstanceOf(ApplicationCommandPermissionsManager); + expect(global.permissions).toBe(global.permissions); + expect(global.permissions.commandId).toBe(commandId); + expect(global.permissions.guildId).toBeNull(); + expect(local.permissions.guildId).toBe(guildId); + expect(client.application.commands.permissions.commandId).toBeNull(); + }); +}); + +describe("ApplicationCommand#equals", () => { + const built = new ApplicationCommand(command()); + + test("GIVEN the same data in each form THEN they are equal", () => { + expect(built.equals(built)).toBe(true); + expect(built.equals(command())).toBe(true); + expect(built.equals(new ApplicationCommand(command()))).toBe(true); + expect( + built.equals({ + name: "awoo", + description: "Howl", + nameLocalizations: { fr: "hurler" }, + descriptionLocalizations: { fr: "Hurle" }, + options: built.options!, + defaultMemberPermissions: PermissionFlagsBits.Administrator, + integrationTypes: [ApplicationIntegrationType.GuildInstall], + contexts: [InteractionContextType.Guild], + } satisfies ApplicationCommandData), + ).toBe(true); + }); + + test("GIVEN a differing field THEN they are not equal", () => { + expect(built.equals(command({ id: "1" }))).toBe(false); + expect(built.equals(command({ name: "other" }))).toBe(false); + expect(built.equals(command({ description: "Other" }))).toBe(false); + expect(built.equals(command({ version: "1" }))).toBe(false); + expect(built.equals(command({ type: ApplicationCommandType.User }))).toBe(false); + expect(built.equals(command({ nsfw: true }))).toBe(false); + expect(built.equals(command({ default_member_permissions: "16" }))).toBe(false); + expect(built.equals(command({ default_member_permissions: null }))).toBe(false); + expect(built.equals(command({ name_localizations: { fr: "x" } }))).toBe(false); + expect(built.equals(command({ description_localizations: null }))).toBe(false); + expect( + built.equals(command({ integration_types: [ApplicationIntegrationType.UserInstall] })), + ).toBe(false); + expect(built.equals(command({ contexts: [InteractionContextType.BotDM] }))).toBe(false); + expect(built.equals(command({ options: options.slice(1) }))).toBe(false); + expect( + built.equals({ + name: "awoo", + defaultMemberPermissions: PermissionFlagsBits.Administrator + 1n, + }), + ).toBe(false); + expect(built.equals({ name: "awoo", handler: 2 })).toBe(false); + }); + + test("GIVEN a foreign argument THEN it is false rather than throwing", () => { + expect(built.equals(null)).toBe(false); + expect(built.equals(undefined)).toBe(false); + expect(built.equals("awoo")).toBe(false); + expect(built.equals(42)).toBe(false); + expect(built.equals({})).toBe(false); + expect(built.equals({ name: "awoo", options: "nope" })).toBe(false); + expect(built.equals({ name: "awoo", defaultMemberPermissions: "NotAPermission" })).toBe(false); + }); + + test("GIVEN options in another order THEN they are equal unless the order is enforced", () => { + const reversed = command({ options: options.toReversed() }); + + expect(built.equals(reversed)).toBe(true); + expect(built.equals(reversed, true)).toBe(false); + }); + + test("GIVEN choices in another order THEN they are equal unless the order is enforced", () => { + const [color] = options as [APIApplicationCommandOption & { choices: unknown[] }]; + const swapped = command({ + options: [{ ...color, choices: color.choices.toReversed() } as never, ...options.slice(1)], + }); + + expect(built.equals(swapped)).toBe(true); + expect(built.equals(swapped, true)).toBe(false); + }); + + test("GIVEN a nested option that differs THEN they are not equal", () => { + const nested = structuredClone(options); + (nested[2] as { options: { description: string }[] }).options[0]!.description = "Other"; + const [color] = options as [{ choices: { name: string; value: string }[] }]; + const otherChoice = structuredClone(options); + (otherChoice[0] as unknown as typeof color).choices[0]!.value = "green"; + const otherChannel = structuredClone(options); + (otherChannel[1] as { channel_types: ChannelType[] }).channel_types = [ + ChannelType.GuildText, + ChannelType.GuildForum, + ]; + + expect(built.equals(command({ options: nested }))).toBe(false); + expect(built.equals(command({ options: otherChoice }))).toBe(false); + expect(built.equals(command({ options: otherChannel }))).toBe(false); + }); + + test("GIVEN optionsEqual THEN it compares recursively and rejects anything else", () => { + expect(ApplicationCommand.optionsEqual(built.options!, options)).toBe(true); + expect(ApplicationCommand.optionsEqual(built.options!, options.slice(1))).toBe(false); + expect(ApplicationCommand.optionsEqual(built.options!, null as never)).toBe(false); + expect(ApplicationCommand.optionsEqual(null as never, options)).toBe(false); + expect(ApplicationCommand.optionsEqual(built.options!, ["a", "b", "c"] as never)).toBe(false); + }); +}); + +describe("ApplicationCommand.transformOption", () => { + test("GIVEN camel-cased data THEN it builds the raw option, and the other way round", () => { + const raw = ApplicationCommand.transformOption({ + type: ApplicationCommandOptionType.Number, + name: "n", + description: "N", + nameLocalizations: { fr: "n" }, + minValue: 1, + maxValue: 5, + choices: [{ name: "one", value: 1, nameLocalizations: { fr: "un" } }], + }); + + expect(raw).toEqual({ + type: ApplicationCommandOptionType.Number, + name: "n", + description: "N", + required: false, + name_localizations: { fr: "n" }, + min_value: 1, + max_value: 5, + choices: [{ name: "one", value: 1, name_localizations: { fr: "un" } }], + }); + expect(ApplicationCommand.transformOption(raw, true)).toEqual({ + type: ApplicationCommandOptionType.Number, + name: "n", + description: "N", + required: false, + nameLocalizations: { fr: "n" }, + minValue: 1, + maxValue: 5, + choices: [{ name: "one", value: 1, nameLocalizations: { fr: "un" } }], + }); + }); +}); + +describe("ApplicationCommand.transformOption nulls", () => { + test("GIVEN null localizations THEN they are kept to remove them", () => { + expect( + ApplicationCommand.transformOption({ + type: ApplicationCommandOptionType.String, + name: "n", + description: "N", + nameLocalizations: null, + descriptionLocalizations: null, + choices: [{ name: "a", value: "a", nameLocalizations: null }], + }), + ).toEqual({ + type: ApplicationCommandOptionType.String, + name: "n", + description: "N", + required: false, + name_localizations: null, + description_localizations: null, + choices: [{ name: "a", value: "a", name_localizations: null }], + }); + }); +}); + +describe("ApplicationCommandManager", () => { + test("GIVEN the managers THEN they are reachable from the application and the guild", () => { + const client = createClient(); + + expect(client.application.commands).toBeInstanceOf(ApplicationCommandManager); + expect(client.application.commands).toBe(client.application.commands); + expect(client.application.commands.guildId).toBeNull(); + expect(client.application.commands.permissions).toBeInstanceOf( + ApplicationCommandPermissionsManager, + ); + const first = client.guilds.commands(guildId); + expect(first).toBeInstanceOf(GuildApplicationCommandManager); + expect(first).toBeInstanceOf(ApplicationCommandManager); + expect(first).not.toBe(client.guilds.commands(guildId)); + expect(first.guildId).toBe(guildId); + expect(first.permissions.guildId).toBe(guildId); + }); + + test("GIVEN commandPath THEN it picks the route from the guild and the command", () => { + const client = createClient(); + const global = client.application.commands; + const local = client.guilds.commands(guildId); + + expect(global.commandPath()).toBe(Routes.applicationCommands(clientId)); + expect(global.commandPath({ id: commandId })).toBe( + Routes.applicationCommand(clientId, commandId), + ); + expect(global.commandPath({ guildId })).toBe( + Routes.applicationGuildCommands(clientId, guildId), + ); + expect(global.commandPath({ id: commandId, guildId })).toBe( + Routes.applicationGuildCommand(clientId, guildId, commandId), + ); + expect(local.commandPath({ guildId: "1" })).toBe( + Routes.applicationGuildCommands(clientId, guildId), + ); + }); + + test("GIVEN fetch without options THEN it lists the global commands by ID", async () => { + const client = createClient(); + const get = vi + .spyOn(container.rest, "get") + .mockResolvedValue([command(), command({ id: "2", name: "other" })]); + + const commands = await client.application.commands.fetch(); + + expect(get).toHaveBeenCalledWith(Routes.applicationCommands(clientId), { + query: expect.any(URLSearchParams), + signal: undefined, + }); + expect(commands).toBeInstanceOf(Collection); + expect([...commands.keys()]).toEqual([commandId, "2"]); + expect(commands.get("2")).toBeInstanceOf(ApplicationCommand); + expect(commands.get("2")?.name).toBe("other"); + }); + + test("GIVEN fetch options THEN it picks the route, the query, and the locale header", async () => { + const client = createClient(); + const get = vi.spyOn(container.rest, "get").mockResolvedValue([command()]); + + const inGuild = await client.application.commands.fetch({ guildId, withLocalizations: true }); + const localized = await client.guilds.commands(guildId).fetch({ locale: Locale.French }); + + const [firstRoute, firstInit] = get.mock.calls[0]!; + expect(firstRoute).toBe(Routes.applicationGuildCommands(clientId, guildId)); + expect((firstInit as { query: URLSearchParams }).query.get("with_localizations")).toBe("true"); + expect(inGuild.get(commandId)?.guildId).toBe(guildId); + + const [secondRoute, secondInit] = get.mock.calls[1]!; + expect(secondRoute).toBe(Routes.applicationGuildCommands(clientId, guildId)); + expect((secondInit as { headers: unknown }).headers).toEqual({ "X-Discord-Locale": "fr" }); + expect(localized.get(commandId)?.guildId).toBe(guildId); + }); + + test("GIVEN fetch of one command THEN it asks for it, with an ID or options", async () => { + const client = createClient(); + const get = vi.spyOn(container.rest, "get").mockResolvedValue(command()); + + const byId = await client.application.commands.fetch(commandId); + const inGuild = await client.application.commands.fetch({ id: commandId, guildId }); + const bound = await client.guilds.commands(otherGuild).fetch({ id: commandId, guildId }); + + expect(get.mock.calls.map(([route]) => route)).toEqual([ + Routes.applicationCommand(clientId, commandId), + Routes.applicationGuildCommand(clientId, guildId, commandId), + Routes.applicationGuildCommand(clientId, otherGuild, commandId), + ]); + expect(byId.guildId).toBeNull(); + expect(inGuild.guildId).toBe(guildId); + expect(bound.guildId).toBe(otherGuild); + }); + + test("GIVEN create THEN it posts the transformed command", async () => { + const client = createClient(); + const post = vi.spyOn(container.rest, "post").mockResolvedValue(command()); + const builder = { toJSON: () => ({ name: "built", description: "From a builder" }) }; + + const created = await client.application.commands.create({ + name: "awoo", + description: "Howl", + defaultMemberPermissions: ["Administrator"], + options: [{ type: ApplicationCommandOptionType.Boolean, name: "on", description: "On" }], + }); + await client.application.commands.create(builder); + await client.application.commands.create({ + name: "snake", + description: "s", + default_member_permissions: "8", + }); + await client.application.commands.create({ name: "local", description: "l" }, guildId); + await client.guilds.commands(otherGuild).create({ name: "bound", description: "b" }, guildId); + + expect(created).toBeInstanceOf(ApplicationCommand); + expect(post.mock.calls.map(([route]) => route)).toEqual([ + Routes.applicationCommands(clientId), + Routes.applicationCommands(clientId), + Routes.applicationCommands(clientId), + Routes.applicationGuildCommands(clientId, guildId), + Routes.applicationGuildCommands(clientId, otherGuild), + ]); + const bodies = post.mock.calls.map(([, init]) => (init as { body: unknown }).body); + expect(bodies[0]).toEqual({ + name: "awoo", + description: "Howl", + default_member_permissions: "8", + options: [ + { + type: ApplicationCommandOptionType.Boolean, + name: "on", + description: "On", + required: false, + }, + ], + }); + expect(bodies[1]).toEqual({ name: "built", description: "From a builder" }); + expect(bodies[2]).toEqual({ name: "snake", description: "s", default_member_permissions: "8" }); + }); + + test("GIVEN set THEN it overwrites the commands and returns them by ID", async () => { + const client = createClient(); + const put = vi.spyOn(container.rest, "put").mockResolvedValue([command()]); + + const global = await client.application.commands.set([{ name: "awoo", description: "Howl" }]); + const emptied = await client.guilds.commands(guildId).set([]); + + expect(put).toHaveBeenNthCalledWith(1, Routes.applicationCommands(clientId), { + body: [{ name: "awoo", description: "Howl" }], + signal: undefined, + }); + expect(put).toHaveBeenNthCalledWith(2, Routes.applicationGuildCommands(clientId, guildId), { + body: [], + signal: undefined, + }); + expect(global.get(commandId)).toBeInstanceOf(ApplicationCommand); + expect(emptied.get(commandId)?.guildId).toBe(guildId); + }); + + test("GIVEN edit and delete THEN they take a command, a raw one, or an ID", async () => { + const client = createClient(); + const patch = vi.spyOn(container.rest, "patch").mockResolvedValue(command({ name: "new" })); + const del = vi.spyOn(container.rest, "delete").mockResolvedValue(undefined); + const manager = client.application.commands; + + const edited = await manager.edit(commandId, { description: "New" }); + await manager.edit(command(), { description: "New" }, guildId); + await manager.delete(command()); + await manager.delete(commandId, guildId); + + expect(edited.name).toBe("new"); + expect(patch.mock.calls.map(([route]) => route)).toEqual([ + Routes.applicationCommand(clientId, commandId), + Routes.applicationGuildCommand(clientId, guildId, commandId), + ]); + expect(del.mock.calls.map(([route]) => route)).toEqual([ + Routes.applicationCommand(clientId, commandId), + Routes.applicationGuildCommand(clientId, guildId, commandId), + ]); + }); + + test("GIVEN a raw guild command THEN edit and delete route to its guild", async () => { + const client = createClient(); + const patch = vi.spyOn(container.rest, "patch").mockResolvedValue(command()); + const del = vi.spyOn(container.rest, "delete").mockResolvedValue(undefined); + const raw = command({ guild_id: guildId }); + + await client.application.commands.edit(raw, { description: "New" }); + await client.application.commands.delete(raw); + + expect(patch.mock.calls.map(([route]) => route)).toEqual([ + Routes.applicationGuildCommand(clientId, guildId, commandId), + ]); + expect(del.mock.calls.map(([route]) => route)).toEqual([ + Routes.applicationGuildCommand(clientId, guildId, commandId), + ]); + }); + + test("GIVEN something without an ID THEN edit and delete throw an InvalidType error", async () => { + const client = createClient(); + const rest = vi.spyOn(container.rest, "patch"); + + for (const run of [ + () => client.application.commands.edit({} as never, {}), + () => client.application.commands.delete(undefined as never), + ]) { + const error = await run().catch((reason: unknown) => reason); + expect(error).toBeInstanceOf(GatewayTypeError); + expect((error as GatewayTypeError).code).toBe(GatewayErrorCodes.InvalidType); + } + expect(rest).not.toHaveBeenCalled(); + }); + + test("GIVEN transformCommand THEN it keeps what is defined and passes builders through", () => { + expect( + ApplicationCommandManager.transformCommand({ + name: "n", + description: "d", + nameLocalizations: { fr: "n" }, + descriptionLocalizations: null, + nsfw: true, + type: ApplicationCommandType.ChatInput, + defaultMemberPermissions: null, + integrationTypes: [ApplicationIntegrationType.UserInstall], + contexts: [InteractionContextType.PrivateChannel], + }), + ).toEqual({ + name: "n", + description: "d", + name_localizations: { fr: "n" }, + description_localizations: null, + nsfw: true, + type: ApplicationCommandType.ChatInput, + default_member_permissions: null, + integration_types: [ApplicationIntegrationType.UserInstall], + contexts: [InteractionContextType.PrivateChannel], + }); + const edit = ApplicationCommandManager.transformCommand({ + name: "n", + defaultMemberPermissions: undefined, + } as never); + expect("default_member_permissions" in edit).toBe(false); + const builder = { toJSON: vi.fn(() => ({ name: "b", description: "b" })) }; + expect(ApplicationCommandManager.transformCommand(builder)).toEqual({ + name: "b", + description: "b", + }); + expect(builder.toJSON).toHaveBeenCalledOnce(); + }); +}); + +describe("ApplicationCommandPermissionsManager", () => { + test("GIVEN permissionsPath THEN it picks the route from the command", () => { + const manager = createClient().application.commands.permissions; + + expect(manager.permissionsPath(guildId, commandId)).toBe( + Routes.applicationCommandPermissions(clientId, guildId, commandId), + ); + expect(manager.permissionsPath(guildId)).toBe( + Routes.guildApplicationCommandsPermissions(clientId, guildId), + ); + }); + + test("GIVEN no guild THEN every method throws GlobalCommandPermissions", async () => { + const manager = createClient().application.commands.permissions; + const rest = vi.spyOn(container.rest, "get"); + + for (const run of [ + () => manager.fetch(), + () => manager.fetch({ command: commandId }), + () => manager.has({ command: commandId, permissionId: userId }), + () => manager.set({ command: commandId, permissions: [], token: "t" }), + () => manager.add({ command: commandId, permissions: [], token: "t" }), + () => manager.remove({ command: commandId, users: [userId], token: "t" }), + ]) { + const error = await run().catch((reason: unknown) => reason); + expect(error).toBeInstanceOf(GatewayError); + expect((error as GatewayError).code).toBe(GatewayErrorCodes.GlobalCommandPermissions); + } + expect(rest).not.toHaveBeenCalled(); + }); + + test("GIVEN fetch of one command THEN it returns its permissions and caches them", async () => { + const client = createClient(); + const get = vi.spyOn(container.rest, "get").mockResolvedValue(permissionsOf()); + + const permissions = await client.application.commands.permissions.fetch({ + guild: guildId, + command: commandId, + }); + + expect(get).toHaveBeenCalledWith( + Routes.applicationCommandPermissions(clientId, guildId, commandId), + { signal: undefined }, + ); + expect(permissions).toHaveLength(3); + expect( + await client.cache!.applicationCommandPermissions!.get( + applicationCommandPermissionsKey(clientId, guildId, commandId), + ), + ).toEqual(permissionsOf()); + }); + + test("GIVEN cached permissions THEN fetch reads them unless forced", async () => { + const client = createClient(); + const get = vi + .spyOn(container.rest, "get") + .mockResolvedValue(permissionsOf({ permissions: [] })); + await client.cache!.applicationCommandPermissions!.set( + applicationCommandPermissionsKey(clientId, guildId, commandId), + permissionsOf(), + ); + const manager = client.guilds.commands(guildId).permissions; + + expect(await manager.fetch({ command: commandId })).toHaveLength(3); + expect(get).not.toHaveBeenCalled(); + expect(await manager.fetch({ command: commandId, force: true })).toEqual([]); + expect(get).toHaveBeenCalledOnce(); + expect(await manager.fetch({ command: commandId })).toEqual([]); + }); + + test("GIVEN a gateway permissions update THEN fetch reads it", async () => { + const client = createClient(); + const get = vi.spyOn(container.rest, "get"); + + client.gateway.emit( + WebSocketShardEvents.Dispatch, + { + op: GatewayOpcodes.Dispatch, + s: 1, + t: GatewayDispatchEvents.ApplicationCommandPermissionsUpdate, + d: permissionsOf(), + } as GatewayDispatchPayload, + 0, + ); + await client.idle(); + + const permissions = await client.guilds + .commands(guildId) + .permissions.fetch({ command: commandId }); + expect(permissions).toHaveLength(3); + expect(get).not.toHaveBeenCalled(); + }); + + test("GIVEN fetch without a command THEN it returns the permissions of every command by ID", async () => { + const client = createClient(); + const get = vi + .spyOn(container.rest, "get") + .mockResolvedValue([permissionsOf(), permissionsOf({ id: clientId, permissions: [] })]); + + const all = await client.guilds.commands(guildId).permissions.fetch(); + + expect(get).toHaveBeenCalledWith( + Routes.guildApplicationCommandsPermissions(clientId, guildId), + { + signal: undefined, + }, + ); + expect(all).toBeInstanceOf(Collection); + expect((all as Collection).get(commandId)).toHaveLength(3); + expect((all as Collection).get(clientId)).toEqual([]); + expect( + await client.cache!.applicationCommandPermissions!.has( + applicationCommandPermissionsKey(clientId, guildId, clientId), + ), + ).toBe(true); + }); + + test("GIVEN a client without the cache THEN fetch always asks the API", async () => { + const client = new GatewayClient({ + discordPublicKey: "0".repeat(64), + discordToken: "test-token", + clientId, + intents: 0, + cache: null, + }); + const get = vi.spyOn(container.rest, "get").mockResolvedValue(permissionsOf()); + + await client.application.commands.permissions.fetch({ guild: guildId, command: commandId }); + await client.application.commands.permissions.fetch({ guild: guildId, command: commandId }); + + expect(get).toHaveBeenCalledTimes(2); + }); + + test("GIVEN a command or guild bound manager THEN it ignores the options it is bound to", async () => { + const client = createClient(); + const get = vi.spyOn(container.rest, "get").mockResolvedValue(permissionsOf()); + const bound = new ApplicationCommand(command({ guild_id: guildId })).permissions; + + await bound.fetch({ guild: otherGuild, command: "1", force: true }); + await client.guilds + .commands(guildId) + .permissions.fetch({ guild: otherGuild, command: commandId, force: true }); + + expect(get.mock.calls.map(([route]) => route)).toEqual([ + Routes.applicationCommandPermissions(clientId, guildId, commandId), + Routes.applicationCommandPermissions(clientId, guildId, commandId), + ]); + }); + + test("GIVEN a guild resolvable THEN the guild of the option is used", async () => { + const client = createClient(); + const get = vi.spyOn(container.rest, "get").mockResolvedValue(permissionsOf()); + const manager = client.application.commands.permissions; + + await manager.fetch({ guild: { guildId } as never, command: commandId, force: true }); + await manager.fetch({ + guild: { id: otherGuild } as never, + command: { id: commandId } as never, + force: true, + }); + + expect(get.mock.calls.map(([route]) => route)).toEqual([ + Routes.applicationCommandPermissions(clientId, guildId, commandId), + Routes.applicationCommandPermissions(clientId, otherGuild, commandId), + ]); + }); + + test("GIVEN set without a token THEN it throws ApplicationCommandPermissionsTokenMissing", async () => { + const client = createClient(); + const put = vi.spyOn(container.rest, "put"); + const manager = client.guilds.commands(guildId).permissions; + + for (const run of [ + () => manager.set({ command: commandId, permissions: [], token: "" }), + () => manager.add({ command: commandId, permissions: [], token: undefined as never }), + () => manager.remove({ command: commandId, users: [userId], token: "" }), + ]) { + const error = await run().catch((reason: unknown) => reason); + expect(error).toBeInstanceOf(GatewayError); + expect((error as GatewayError).code).toBe( + GatewayErrorCodes.ApplicationCommandPermissionsTokenMissing, + ); + } + expect(put).not.toHaveBeenCalled(); + }); + + test("GIVEN set THEN it puts the permissions with the bearer token, and caches the result", async () => { + const client = createClient(); + const put = vi.spyOn(container.rest, "put").mockResolvedValue(permissionsOf()); + const permissions = permissionsOf().permissions; + + const stored = await client.guilds + .commands(guildId) + .permissions.set({ command: commandId, permissions, token: "user-token" }); + + expect(put).toHaveBeenCalledWith( + Routes.applicationCommandPermissions(clientId, guildId, commandId), + { + auth: false, + headers: { Authorization: "Bearer user-token" }, + body: { permissions }, + signal: undefined, + }, + ); + expect(stored).toEqual(permissions); + expect( + await client.cache!.applicationCommandPermissions!.get( + applicationCommandPermissionsKey(clientId, guildId, commandId), + ), + ).toEqual(permissionsOf()); + }); + + test("GIVEN set without a command THEN it edits the permissions of the guild as a whole", async () => { + const client = createClient(); + const put = vi.spyOn(container.rest, "put").mockResolvedValue(permissionsOf({ id: clientId })); + + await client.application.commands.permissions.set({ + guild: guildId, + permissions: [], + token: "t", + }); + + expect(put.mock.calls[0]?.[0]).toBe( + Routes.applicationCommandPermissions(clientId, guildId, clientId), + ); + }); + + test("GIVEN set with permissions that are no array THEN it throws an InvalidType error", async () => { + const client = createClient(); + const put = vi.spyOn(container.rest, "put"); + + const error = await client.guilds + .commands(guildId) + .permissions.set({ command: commandId, permissions: "all" as never, token: "t" }) + .catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(GatewayTypeError); + expect((error as GatewayTypeError).code).toBe(GatewayErrorCodes.InvalidType); + expect(put).not.toHaveBeenCalled(); + }); + + test("GIVEN add THEN it merges into the permissions the API has, new ones first", async () => { + const client = createClient(); + const get = vi.spyOn(container.rest, "get").mockResolvedValue(permissionsOf()); + const put = vi.spyOn(container.rest, "put").mockResolvedValue(permissionsOf()); + // A cached copy must not stand in for what the write merges into. + await client.cache!.applicationCommandPermissions!.set( + applicationCommandPermissionsKey(clientId, guildId, commandId), + permissionsOf({ permissions: [] }), + ); + + await client.guilds.commands(guildId).permissions.add({ + command: commandId, + token: "t", + permissions: [ + { id: roleId, type: ApplicationCommandPermissionType.Role, permission: true }, + { id: otherGuild, type: ApplicationCommandPermissionType.Role, permission: false }, + ], + }); + + expect(get).toHaveBeenCalledOnce(); + expect((put.mock.calls[0]![1] as { body: unknown }).body).toEqual({ + permissions: [ + { id: roleId, type: ApplicationCommandPermissionType.Role, permission: true }, + { id: otherGuild, type: ApplicationCommandPermissionType.Role, permission: false }, + { id: userId, type: ApplicationCommandPermissionType.User, permission: true }, + { id: channelId, type: ApplicationCommandPermissionType.Channel, permission: true }, + ], + }); + }); + + test("GIVEN a command without permissions THEN add treats them as empty", async () => { + const client = createClient(); + vi.spyOn(container.rest, "get").mockRejectedValue(unknownPermissions()); + const put = vi.spyOn(container.rest, "put").mockResolvedValue(permissionsOf()); + const permission = { + id: userId, + type: ApplicationCommandPermissionType.User, + permission: true, + }; + + await client.guilds + .commands(guildId) + .permissions.add({ command: commandId, token: "t", permissions: [permission] }); + + expect((put.mock.calls[0]![1] as { body: unknown }).body).toEqual({ + permissions: [permission], + }); + }); + + test("GIVEN another error THEN add rethrows it", async () => { + const client = createClient(); + const failure = new Error("boom"); + vi.spyOn(container.rest, "get").mockRejectedValue(failure); + const put = vi.spyOn(container.rest, "put"); + + await expect( + client.guilds + .commands(guildId) + .permissions.add({ command: commandId, token: "t", permissions: [] }), + ).rejects.toBe(failure); + expect(put).not.toHaveBeenCalled(); + }); + + test("GIVEN remove THEN it puts what is left, resolving IDs and structures", async () => { + const client = createClient(); + vi.spyOn(container.rest, "get").mockResolvedValue(permissionsOf()); + const put = vi.spyOn(container.rest, "put").mockResolvedValue(permissionsOf()); + const manager = client.guilds.commands(guildId).permissions; + + await manager.remove({ command: commandId, token: "t", roles: [{ id: roleId } as never] }); + await manager.remove({ + command: commandId, + token: "t", + users: [userId], + channels: [channelId], + }); + await manager.remove({ command: commandId, token: "t", users: [], roles: [] }); + + const bodies = put.mock.calls.map( + ([, init]) => (init as { body: { permissions: { id: string }[] } }).body, + ); + expect(bodies[0]!.permissions.map((permission) => permission.id)).toEqual([userId, channelId]); + expect(bodies[1]!.permissions.map((permission) => permission.id)).toEqual([roleId]); + expect(bodies[2]!.permissions).toHaveLength(3); + }); + + test("GIVEN remove with nothing to remove THEN it throws, and so does a bad element", async () => { + const client = createClient(); + const put = vi.spyOn(container.rest, "put"); + const manager = client.guilds.commands(guildId).permissions; + + for (const input of [ + {}, + { users: "nope" }, + { roles: [{}] }, + { channels: [null] }, + { users: [{}] }, + ]) { + const error = await manager + .remove({ command: commandId, token: "t", ...input } as never) + .catch((reason: unknown) => reason); + expect(error).toBeInstanceOf(GatewayTypeError); + expect((error as GatewayTypeError).code).toMatch(/^Invalid(Type|Element)$/); + } + expect(put).not.toHaveBeenCalled(); + }); + + test("GIVEN has THEN it looks the ID up, by type when asked", async () => { + const client = createClient(); + vi.spyOn(container.rest, "get").mockResolvedValue(permissionsOf()); + const manager = client.guilds.commands(guildId).permissions; + + expect(await manager.has({ command: commandId, permissionId: userId })).toBe(true); + expect(await manager.has({ command: commandId, permissionId: { id: roleId } as never })).toBe( + true, + ); + expect(await manager.has({ command: commandId, permissionId: otherGuild })).toBe(false); + expect( + await manager.has({ + command: commandId, + permissionId: userId, + permissionType: ApplicationCommandPermissionType.Role, + }), + ).toBe(false); + }); + + test("GIVEN has without a command or an ID THEN it throws, and a command without permissions has none", async () => { + const client = createClient(); + const get = vi.spyOn(container.rest, "get").mockRejectedValue(unknownPermissions()); + const manager = client.guilds.commands(guildId).permissions; + + expect(await manager.has({ command: commandId, permissionId: userId })).toBe(false); + for (const input of [ + { permissionId: userId }, + { command: commandId, permissionId: undefined }, + ]) { + const error = await manager.has(input as never).catch((reason: unknown) => reason); + expect(error).toBeInstanceOf(GatewayTypeError); + expect((error as GatewayTypeError).code).toBe(GatewayErrorCodes.InvalidType); + } + expect(get).toHaveBeenCalledOnce(); + }); + + test("GIVEN the application manager THEN a command resolvable without an ID throws an InvalidType error", async () => { + const manager = createClient().application.commands.permissions; + + const error = await manager + .fetch({ guild: guildId, command: {} as never }) + .catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(GatewayTypeError); + expect((error as GatewayTypeError).code).toBe(GatewayErrorCodes.InvalidType); + }); +}); diff --git a/packages/plugin-gateway/tests/types/application-commands.ts b/packages/plugin-gateway/tests/types/application-commands.ts new file mode 100644 index 00000000..fb5a30f8 --- /dev/null +++ b/packages/plugin-gateway/tests/types/application-commands.ts @@ -0,0 +1,125 @@ +// Type-level test, checked by the root `typecheck` script through `tsconfig.consumption.json`: the application command +// managers and structure have the shapes of discord.js's, and their resolvable types accept what discord.js's do. +import type { Collection } from "@discordjs/collection"; +import type { + APIApplicationCommand, + APIApplicationCommandOption, + Locale, + RESTPostAPIApplicationCommandsJSONBody, +} from "discord-api-types/v10"; +import type { + ApplicationCommand, + ApplicationCommandData, + ApplicationCommandDataResolvable, + ApplicationCommandManager, + ApplicationCommandOption, + ApplicationCommandPermissionIdResolvable, + ApplicationCommandPermissions, + ApplicationCommandPermissionsManager, + ApplicationCommandResolvable, + FetchApplicationCommandOptions, + GatewayClient, + Guild, + GuildApplicationCommandManager, + PermissionsBitField, + Role, + User, +} from "../../src/index.js"; + +declare const client: GatewayClient; +declare const guild: Guild; +declare const command: ApplicationCommand; +declare const apiCommand: APIApplicationCommand; +declare const option: APIApplicationCommandOption; +declare const role: Role; +declare const user: User; +declare const builder: { toJSON(): RESTPostAPIApplicationCommandsJSONBody }; +declare const locale: Locale; + +// The managers hang off the application, the guild, and the command. +export const global: ApplicationCommandManager = client.application.commands; +export const local: GuildApplicationCommandManager = guild.commands; +export const guildCommands: GuildApplicationCommandManager = client.guilds.commands("1"); +export const guildId: string = local.guildId; +export const maybeGuildId: string | null = global.guildId; +export const permissions: ApplicationCommandPermissionsManager = global.permissions; +export const ownPermissions: ApplicationCommandPermissionsManager = command.permissions; +export const manager: ApplicationCommandManager = command.manager; + +// Fetching one command, or every command. +export const all: Promise> = global.fetch(); +export const allInGuild: Promise> = global.fetch({ + guildId: "1", + locale: "fr", + withLocalizations: true, +}); +export const one: Promise = global.fetch("1"); +export const oneInGuild: Promise = global.fetch({ id: "1", guildId: "2" }); +export const options: FetchApplicationCommandOptions = { locale, withLocalizations: false }; + +// Resolvables. +export const resolvables: ApplicationCommandResolvable[] = [command, apiCommand, "1"]; +export const dataResolvables: ApplicationCommandDataResolvable[] = [ + { name: "awoo", description: "Howl", defaultMemberPermissions: ["Administrator"] }, + { name: "awoo", description: "Howl", options: [option] }, + builder, +]; +export const data: ApplicationCommandData = { name: "awoo", nameLocalizations: null }; +export const created: Promise = global.create(data); +export const createdInGuild: Promise = global.create(builder, "1"); +export const overwritten: Promise> = global.set([ + data, + builder, +]); +export const edited: Promise = global.edit( + command, + { description: "Awoo" }, + "1", +); +export const deleted: Promise = local.delete("1"); +declare const transformCommand: typeof ApplicationCommandManager.transformCommand; +export const transformed: RESTPostAPIApplicationCommandsJSONBody = transformCommand(builder); + +// The structure. +export const optionList: ApplicationCommandOption[] | null = command.options; +export const defaultPermissions: Readonly | null = + command.defaultMemberPermissions; +export const guildOfCommand: Guild | null = command.guild; +export const same: boolean = command.equals(apiCommand, true); +export const sameForeign: boolean = command.equals(null); +declare const optionsEqual: typeof ApplicationCommand.optionsEqual; +export const sameOptions: boolean = optionsEqual(command.options ?? [], [option], true); +export const editedCommand: Promise = command.setName("awoo"); +export const deletedCommand: Promise = command.delete(); + +// Permissions. +export const own: Promise = permissions.fetch({ command }); +export const everyCommand: Promise< + ApplicationCommandPermissions[] | Collection +> = permissions.fetch({ guild }); +export const ids: ApplicationCommandPermissionIdResolvable[] = [role, user, "1"]; +export const has: Promise = permissions.has({ command, permissionId: role }); +export const set: Promise = permissions.set({ + command, + permissions: [], + token: "token", +}); +export const removed: Promise = permissions.remove({ + command, + token: "token", + users: [user], + roles: [role, "1"], + channels: ["2"], +}); + +// @ts-expect-error a bearer token is needed to write permissions. +permissions.set({ command, permissions: [] }); + +// @ts-expect-error a number is no command. +export const badCommand: ApplicationCommandResolvable = 1; + +// @ts-expect-error a number is no permission ID. +permissions.has({ command, permissionId: 1 }); + +// @ts-expect-error `guildId` of a guild's commands is read-only. +local.guildId = "1";