Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(ApplicationCommands): allow managing commands for uncached guilds #5729

Merged
merged 3 commits into from Jun 11, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/errors/Messages.js
Expand Up @@ -109,7 +109,8 @@ const Messages = {
MEMBER_FETCH_NONCE_LENGTH: 'Nonce length must not exceed 32 characters.',

GLOBAL_COMMAND_PERMISSIONS:
"Permissions for global commands may only be fetched or modified from a guild's application command manager.",
'Permissions for global commands may only be fetched or modified by providing a guildID' +
"or from a guild's application command manager.",

INTERACTION_ALREADY_REPLIED: 'This interaction has already been deferred or replied to.',
};
Expand Down
198 changes: 179 additions & 19 deletions src/managers/ApplicationCommandManager.js
@@ -1,9 +1,10 @@
'use strict';

const BaseManager = require('./BaseManager');
const { TypeError } = require('../errors');
const { Error, TypeError } = require('../errors');
const ApplicationCommand = require('../structures/ApplicationCommand');
const Collection = require('../util/Collection');
const { ApplicationCommandPermissionTypes } = require('../util/Constants');

/**
* Manages API methods for application commands and stores their cache.
Expand All @@ -26,14 +27,16 @@ class ApplicationCommandManager extends BaseManager {

/**
* The APIRouter path to the commands
* @type {Object}
* @readonly
* @param {Snowflake} [options.id] ID of the application command
* @param {Snowflake} [options.guildID] ID of the guild to use in the path,
* ignored when using a {@link GuildApplicationCommandManager}
* @returns {Object}
* @private
*/
get commandPath() {
commandPath({ id, guildID } = {}) {
let path = this.client.api.applications(this.client.application.id);
if (this.guild) path = path.guilds(this.guild.id);
return path.commands;
if (this.guild || guildID) path = path.guilds(this.guild?.id ?? guildID);
return id ? path.commands(id) : path.commands;
}

/**
Expand All @@ -43,11 +46,23 @@ class ApplicationCommandManager extends BaseManager {
* @typedef {ApplicationCommand|Snowflake} ApplicationCommandResolvable
*/

/**
* Options used to fetch data from discord
* @typedef {Object} BaseFetchOptions
* @property {boolean} [cache=true] Whether to cache the fetched data if it wasn't already
* @property {boolean} [force=false] Whether to skip the cache check and request the API
*/

/**
* Options used to fetch Application Commands from discord
* @typedef {BaseFetchOptions} FetchApplicationCommandOptions
* @property {Snowflake} [guildID] ID of the guild to fetch commands for, for when the guild is not cached
*/

/**
* Obtains one or multiple application commands from Discord, or the cache if it's already available.
* @param {Snowflake} [id] ID of the application command
* @param {boolean} [cache=true] Whether to cache the new application commands if they weren't already
* @param {boolean} [force=false] Whether to skip the cache check and request the API
* @param {FetchApplicationCommandOptions} [options] Additional options for this fetch
* @returns {Promise<ApplicationCommand|Collection<Snowflake, ApplicationCommand>>}
* @example
* // Fetch a single command
Expand All @@ -60,23 +75,29 @@ class ApplicationCommandManager extends BaseManager {
* .then(commands => console.log(`Fetched ${commands.size} commands`))
* .catch(console.error);
*/
async fetch(id, cache = true, force = false) {
async fetch(id, { guildID, cache = true, force = false } = {}) {
if (typeof id === 'object') {
({ guildID, cache = true, force = false } = id);
id = undefined;
}
if (id) {
if (!force) {
const existing = this.cache.get(id);
if (existing) return existing;
}
const command = await this.commandPath(id).get();
const command = await this.commandPath({ id, guildID }).get();
return this.add(command, cache);
}

const data = await this.commandPath.get();
const data = await this.commandPath({ guildID }).get();
return data.reduce((coll, command) => coll.set(command.id, this.add(command, cache)), new Collection());
}

/**
* Creates an application command.
* @param {ApplicationCommandData} command The command
* @param {Snowflake} [guildID] ID of the guild to create this command in,
* ignored when using a {@link GuildApplicationCommandManager}
* @returns {Promise<ApplicationCommand>}
* @example
* // Create a new command
Expand All @@ -87,8 +108,8 @@ class ApplicationCommandManager extends BaseManager {
* .then(console.log)
* .catch(console.error);
*/
async create(command) {
const data = await this.commandPath.post({
async create(command, guildID) {
const data = await this.commandPath({ guildID }).post({
data: this.constructor.transformCommand(command),
});
return this.add(data);
Expand All @@ -97,6 +118,8 @@ class ApplicationCommandManager extends BaseManager {
/**
* Sets all the commands for this application or guild.
* @param {ApplicationCommandData[]} commands The commands
* @param {Snowflake} [guildID] ID of the guild to create the commands in,
* ignored when using a {@link GuildApplicationCommandManager}
* @returns {Promise<Collection<Snowflake, ApplicationCommand>>}
* @example
* // Set all commands to just this one
Expand All @@ -114,8 +137,8 @@ class ApplicationCommandManager extends BaseManager {
* .then(console.log)
* .catch(console.error);
*/
async set(commands) {
const data = await this.commandPath.put({
async set(commands, guildID) {
const data = await this.commandPath({ guildID }).put({
data: commands.map(c => this.constructor.transformCommand(c)),
});
return data.reduce((coll, command) => coll.set(command.id, this.add(command)), new Collection());
Expand All @@ -125,6 +148,8 @@ class ApplicationCommandManager extends BaseManager {
* Edits an application command.
* @param {ApplicationCommandResolvable} command The command to edit
* @param {ApplicationCommandData} data The data to update the command with
* @param {Snowflake} [guildID] ID of the guild where the command registered,
* ignored when using a {@link GuildApplicationCommandManager}
* @returns {Promise<ApplicationCommand>}
* @example
* // Edit an existing command
Expand All @@ -134,29 +159,31 @@ class ApplicationCommandManager extends BaseManager {
* .then(console.log)
* .catch(console.error);
*/
async edit(command, data) {
async edit(command, data, guildID) {
const id = this.resolveID(command);
if (!id) throw new TypeError('INVALID_TYPE', 'command', 'ApplicationCommandResolvable');

const patched = await this.commandPath(id).patch({ data: this.constructor.transformCommand(data) });
const patched = await this.commandPath({ id, guildID }).patch({ data: this.constructor.transformCommand(data) });
return this.add(patched);
}

/**
* Deletes an application command.
* @param {ApplicationCommandResolvable} command The command to delete
* @param {Snowflake} [guildID] ID of the guild where the command is registered,
* ignored when using a {@link GuildApplicationCommandManager}
* @returns {Promise<?ApplicationCommand>}
* @example
* // Delete a command
* guild.commands.delete('123456789012345678')
* .then(console.log)
* .catch(console.error);
*/
async delete(command) {
async delete(command, guildID) {
const id = this.resolveID(command);
if (!id) throw new TypeError('INVALID_TYPE', 'command', 'ApplicationCommandResolvable');

await this.commandPath(id).delete();
await this.commandPath({ id, guildID }).delete();

const cached = this.cache.get(id);
this.cache.delete(id);
Expand All @@ -177,6 +204,139 @@ class ApplicationCommandManager extends BaseManager {
default_permission: command.defaultPermission,
};
}

/**
* Fetches the permissions for one or multiple commands.
* <warn>When calling this on ApplicationCommandManager, guildID is required.
* To fetch all permissions for an uncached guild use `fetchPermissions(undefined, '123456789012345678')`</warn>
Copy link
Member Author

@ckohen ckohen Jun 2, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just wanted to make a note here about passing undefined. I don't love this pattern, but as Monbrey said in the discord, I don't love having to pass { command: '123456789012345678' } either.

Copy link
Member

@monbrey monbrey Jun 2, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the guild ID is the required one, put it first?

fetchPermissions(guild)
fetchPermissions(guild, command)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when called from a GuildApplicationCommandManager its not required, although I suppose I could just use super in that class.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I tried to do this, and it simply doesn't work as wanted.
Putting guildID as the first parameter means implementing fetchPermissions and setPermissions in GuildApplicationCommandManager which we can do in js, but because guildID is not a parameter there, typescript really doesn't like it, suggesting this is a bad pattern in general.

Copy link
Member Author

@ckohen ckohen Jun 2, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any other suggestions for this (or a suggestion for how to make typescript happy with different method signatures 😆 )?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

override still doesn't let you remove / repurpose parameters in the signature though from what I can tell.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not nice, but you could allow either just a command resolvable, or an object with it and the guild.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is precedent for this elsewhere in the library actulaly, so it works, will do.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A CommandResolvable can't be accepted as the only parameter because this.resolve (when providing an id) can't resolve guild application commands, which are required when not passing guildID so that we can get command.guild.id instead. And typescript will complain if I try to make it ApplicationCommand | { guildID: Snowflake | command?: ApplciationCommandResolvable } and then override that to ApplicationCommandResolvable in the subclass

Copy link

@zakuciael zakuciael Jun 6, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excuse a stranger, but if I've understood correctly the goal here is to somehow put the guildId parameter to the fetchPermissions method (possibly to more methods) and at the same time satisfy the TypeScript compiler in the GuildApplicationCommandManager, right?

So I've created a small "demo" of my approach on the TS Playground here and with this solution you can pass all combinations: no params, only command and guild id + previous combinations. I've based it on the @SpaceEEC suggestion to put either a resolvable or an object with guildId and the command.

Note: The GuildApplicationCommandResolvable type can be simplified to this (see below) by doing that the usage of the Optional<T> type can be avoided.

type GuildApplicationCommandResolvable = { guildId: Snowflake, command?: ApplicationCommandResolvable }

(I know that I wrote it entirely in TypeScript, and it may or may not be 100% applicable but maybe at the very least it will notch you guys in the right direction, hope I've helped)

* @param {ApplicationCommandResolvable} [command] The command to get the permissions from
* @param {Snowflake} [guildID] ID of the guild to get the permissions for,
* ignored when using a {@link GuildApplicationCommandManager}
* @returns {Promise<ApplicationCommandPermissions[]|Collection<Snowflake, ApplicationCommandPermissions[]>>}
* @example
* // Fetch permissions for one command
* guild.commands.fetchPermissions('123456789012345678')
* .then(perms => console.log(`Fetched permissions for ${perms.length} users`))
* .catch(console.error);
* @example
* // Fetch permissions for all commands
* client.application.commands.fetchPermissions()
* .then(perms => console.log(`Fetched permissions for ${perms.size} commands`))
* .catch(console.error);
*/
async fetchPermissions(command, guildID) {
if (!this.guild && !guildID) throw new Error('GLOBAL_COMMAND_PERMISSIONS');
if (command) {
const id = this.resolveID(command);
if (!id) throw new TypeError('INVALID_TYPE', 'command', 'ApplicationCommandResolvable');

const data = await this.commandPath({ id, guildID }).permissions.get();
return data.permissions.map(perm => this.constructor.transformPermissions(perm, true));
}

const data = await this.commandPath({ guildID }).permissions.get();
return data.reduce(
(coll, perm) =>
coll.set(
perm.id,
perm.permissions.map(p => this.constructor.transformPermissions(p, true)),
),
new Collection(),
);
}

/**
* Data used for overwriting the permissions for all application commands in a guild.
* @typedef {Object} GuildApplicationCommandPermissionData
* @prop {Snowflake} command The ID of the command
* @prop {ApplicationCommandPermissionData[]} permissions The permissions for this command
*/

/**
* Sets the permissions for a command.
* <warn>When calling this on ApplicationCommandManager, guildID is required.
* To set multiple permissions for an uncached guild use `setPermissions(permissions, '123456789012345678')`</warn>
* @param {ApplicationCommandResolvable|GuildApplicationCommandPermissionData[]} command The command to edit the
* permissions for, or an array of guild application command permissions to set the permissions of all commands
* @param {ApplicationCommandPermissionData[]} [permissions] The new permissions for the command
* @param {Snowflake} [guildID] ID of the guild to get the permissions for,
* ignored when using a {@link GuildApplicationCommandManager}
* @returns {Promise<ApplicationCommandPermissions[]|Collection<Snowflake, ApplicationCommandPermissions[]>>}
* @example
* // Set the permissions for one command
* client.application.commands.setPermissions('123456789012345678', [
* {
* id: '876543210987654321',
* type: 'USER',
* permission: false,
* },
* ])
* .then(console.log)
* .catch(console.error);
* @example
* // Set the permissions for all commands
* guild.commands.setPermissions([
* {
* id: '123456789012345678',
* permissions: [{
* id: '876543210987654321',
* type: 'USER',
* permission: false,
* }],
* },
* ])
* .then(console.log)
* .catch(console.error);
*/
async setPermissions(command, permissions, guildID) {
if (!this.guild && !guildID) throw new Error('GLOBAL_COMMAND_PERMISSIONS');
const id = this.resolveID(command);

if (id) {
const data = await this.commandPath({ id, guildID }).permissions.put({
data: { permissions: permissions.map(perm => this.constructor.transformPermissions(perm)) },
});
return data.permissions.map(perm => this.constructor.transformPermissions(perm, true));
}

if (typeof permissions === 'string') {
guildID = permissions;
permissions = undefined;
}

const data = await this.commandPath({ guildID }).permissions.put({
data: command.map(perm => ({
id: perm.id,
permissions: perm.permissions.map(p => this.constructor.transformPermissions(p)),
})),
});
return data.reduce(
(coll, perm) =>
coll.set(
perm.id,
perm.permissions.map(p => this.constructor.transformPermissions(p, true)),
),
new Collection(),
);
}

/**
* Transforms an {@link ApplicationCommandPermissionData} object into something that can be used with the API.
* @param {ApplicationCommandPermissionData} permissions The permissions to transform
* @param {boolean} [received] Whether these permissions have been received from Discord
* @returns {Object}
* @private
*/
static transformPermissions(permissions, received) {
return {
id: permissions.id,
permission: permissions.permission,
type:
typeof permissions.type === 'number' && !received
? permissions.type
: ApplicationCommandPermissionTypes[permissions.type],
};
}
}

module.exports = ApplicationCommandManager;