From ddf759c8117e7a00702d444f5b5f0c2257189b09 Mon Sep 17 00:00:00 2001 From: Micah Benac <66775276+OfficialSirH@users.noreply.github.com> Date: Thu, 28 Oct 2021 18:47:50 -0400 Subject: [PATCH] feat: add support for autocomplete interactions (#6672) Co-authored-by: Suneet Tipirneni --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- package-lock.json | 1 - src/client/actions/InteractionCreate.js | 4 + src/errors/Messages.js | 1 + src/index.js | 1 + src/structures/ApplicationCommand.js | 5 + src/structures/AutocompleteInteraction.js | 107 ++++++++++++++++++ src/structures/BaseCommandInteraction.js | 8 +- .../CommandInteractionOptionResolver.js | 12 ++ src/structures/Interaction.js | 8 ++ src/util/Constants.js | 9 +- typings/enums.d.ts | 2 + typings/index.d.ts | 71 +++++++++++- typings/tests.ts | 9 +- 14 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 src/structures/AutocompleteInteraction.js diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 76a8ea0b0f16..f3d4e471147e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -60,7 +60,7 @@ body: label: Node.js version description: | Which version of Node.js are you using? Run `node --version` in your project directory and paste the output. - If you are using TypeScript, please include its version (`npm list typescript`) as well. + If you are using TypeScript, please include its version (`npm list typescript`) as well. placeholder: Node.js version 16.6+ is required for version 13.0.0+ validations: required: true diff --git a/package-lock.json b/package-lock.json index 7c13a714b624..84b70b45c35c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,6 @@ "requires": true, "packages": { "": { - "name": "discord.js", "version": "13.3.0-dev", "license": "Apache-2.0", "dependencies": { diff --git a/src/client/actions/InteractionCreate.js b/src/client/actions/InteractionCreate.js index 9a7ff127978c..7a4338fbb476 100644 --- a/src/client/actions/InteractionCreate.js +++ b/src/client/actions/InteractionCreate.js @@ -1,6 +1,7 @@ 'use strict'; const Action = require('./Action'); +const AutocompleteInteraction = require('../../structures/AutocompleteInteraction'); const ButtonInteraction = require('../../structures/ButtonInteraction'); const CommandInteraction = require('../../structures/CommandInteraction'); const ContextMenuInteraction = require('../../structures/ContextMenuInteraction'); @@ -51,6 +52,9 @@ class InteractionCreateAction extends Action { return; } break; + case InteractionTypes.APPLICATION_COMMAND_AUTOCOMPLETE: + InteractionType = AutocompleteInteraction; + break; default: client.emit(Events.DEBUG, `[INTERACTION] Received interaction with unknown type: ${data.type}`); return; diff --git a/src/errors/Messages.js b/src/errors/Messages.js index c76b2c61178a..e20aadda9971 100644 --- a/src/errors/Messages.js +++ b/src/errors/Messages.js @@ -143,6 +143,7 @@ const Messages = { `Required option "${name}" is of type: ${type}; expected a non-empty value.`, COMMAND_INTERACTION_OPTION_NO_SUB_COMMAND: 'No subcommand specified for interaction.', COMMAND_INTERACTION_OPTION_NO_SUB_COMMAND_GROUP: 'No subcommand group specified for interaction.', + AUTOCOMPLETE_INTERACTION_OPTION_NO_FOCUSED_OPTION: 'No focused option for autocomplete interaction.', INVITE_MISSING_SCOPES: 'At least one valid scope must be provided for the invite', diff --git a/src/index.js b/src/index.js index 9593167794eb..aaf5f970304b 100644 --- a/src/index.js +++ b/src/index.js @@ -69,6 +69,7 @@ exports.Activity = require('./structures/Presence').Activity; exports.AnonymousGuild = require('./structures/AnonymousGuild'); exports.Application = require('./structures/interfaces/Application'); exports.ApplicationCommand = require('./structures/ApplicationCommand'); +exports.AutocompleteInteraction = require('./structures/AutocompleteInteraction'); exports.Base = require('./structures/Base'); exports.BaseCommandInteraction = require('./structures/BaseCommandInteraction'); exports.BaseGuild = require('./structures/BaseGuild'); diff --git a/src/structures/ApplicationCommand.js b/src/structures/ApplicationCommand.js index b6c99a443587..b026d4ceae26 100644 --- a/src/structures/ApplicationCommand.js +++ b/src/structures/ApplicationCommand.js @@ -140,6 +140,7 @@ class ApplicationCommand extends Base { * @property {ApplicationCommandOptionType|number} type The type of the option * @property {string} name The name of the option * @property {string} description The description of the option + * @property {boolean} [autocomplete] Whether the option is an autocomplete option * @property {boolean} [required] Whether the option is required * @property {ApplicationCommandOptionChoice[]} [choices] The choices of the option for the user to pick from * @property {ApplicationCommandOptionData[]} [options] Additional options if this option is a subcommand (group) @@ -199,6 +200,7 @@ class ApplicationCommand extends Base { command.name !== this.name || ('description' in command && command.description !== this.description) || ('version' in command && command.version !== this.version) || + ('autocomplete' in command && command.autocomplete !== this.autocomplete) || (commandType && commandType !== this.type) || // Future proof for options being nullable // TODO: remove ?? 0 on each when nullable @@ -254,6 +256,7 @@ class ApplicationCommand extends Base { option.name !== existing.name || optionType !== existing.type || option.description !== existing.description || + option.autocomplete !== existing.autocomplete || (option.required ?? (['SUB_COMMAND', 'SUB_COMMAND_GROUP'].includes(optionType) ? undefined : false)) !== existing.required || option.choices?.length !== existing.choices?.length || @@ -303,6 +306,7 @@ class ApplicationCommand extends Base { * @property {string} name The name of the option * @property {string} description The description of the option * @property {boolean} [required] Whether the option is required + * @property {boolean} [autocomplete] Whether the option is an autocomplete option * @property {ApplicationCommandOptionChoice[]} [choices] The choices of the option for the user to pick from * @property {ApplicationCommandOption[]} [options] Additional options if this option is a subcommand (group) * @property {ChannelType[]} [channelTypes] When the option type is channel, @@ -332,6 +336,7 @@ class ApplicationCommand extends Base { description: option.description, required: option.required ?? (stringType === 'SUB_COMMAND' || stringType === 'SUB_COMMAND_GROUP' ? undefined : false), + autocomplete: option.autocomplete, choices: option.choices, options: option.options?.map(o => this.transformOption(o, received)), [channelTypesKey]: received diff --git a/src/structures/AutocompleteInteraction.js b/src/structures/AutocompleteInteraction.js new file mode 100644 index 000000000000..4c5d8369677f --- /dev/null +++ b/src/structures/AutocompleteInteraction.js @@ -0,0 +1,107 @@ +'use strict'; + +const CommandInteractionOptionResolver = require('./CommandInteractionOptionResolver'); +const Interaction = require('./Interaction'); +const { InteractionResponseTypes, ApplicationCommandOptionTypes } = require('../util/Constants'); + +/** + * Represents an autocomplete interaction. + * @extends {Interaction} + */ +class AutocompleteInteraction extends Interaction { + constructor(client, data) { + super(client, data); + + /** + * The id of the channel this interaction was sent in + * @type {Snowflake} + * @name AutocompleteInteraction#channelId + */ + + /** + * The invoked application command's id + * @type {Snowflake} + */ + this.commandId = data.data.id; + + /** + * The invoked application command's name + * @type {string} + */ + this.commandName = data.data.name; + + /** + * Whether this interaction has already received a response + * @type {boolean} + */ + this.responded = false; + + /** + * The options passed to the command + * @type {CommandInteractionOptionResolver} + */ + this.options = new CommandInteractionOptionResolver( + this.client, + data.data.options?.map(option => this.transformOption(option, data.data.resolved)) ?? [], + ); + } + + /** + * The invoked application command, if it was fetched before + * @type {?ApplicationCommand} + */ + get command() { + const id = this.commandId; + return this.guild?.commands.cache.get(id) ?? this.client.application.commands.cache.get(id) ?? null; + } + + /** + * Transforms an option received from the API. + * @param {APIApplicationCommandOption} option The received option + * @returns {CommandInteractionOption} + * @private + */ + transformOption(option) { + const result = { + name: option.name, + type: ApplicationCommandOptionTypes[option.type], + }; + + if ('value' in option) result.value = option.value; + if ('options' in option) result.options = option.options.map(opt => this.transformOption(opt)); + if ('focused' in option) result.focused = option.focused; + + return result; + } + + /** + * Sends results for the autocomplete of this interaction. + * @param {ApplicationCommandOptionChoice[]} options The options for the autocomplete + * @returns {Promise} + * @example + * // respond to autocomplete interaction + * interaction.respond([ + * { + * name: 'Option 1', + * value: 'option1', + * }, + * ]) + * .then(console.log) + * .catch(console.error); + */ + async respond(options) { + if (this.responded) throw new Error('INTERACTION_ALREADY_REPLIED'); + + await this.client.api.interactions(this.id, this.token).callback.post({ + data: { + type: InteractionResponseTypes.APPLICATION_COMMAND_AUTOCOMPLETE_RESULT, + data: { + choices: options, + }, + }, + }); + this.responded = true; + } +} + +module.exports = AutocompleteInteraction; diff --git a/src/structures/BaseCommandInteraction.js b/src/structures/BaseCommandInteraction.js index ec6eb4fd7f5d..0ddaf6bfee48 100644 --- a/src/structures/BaseCommandInteraction.js +++ b/src/structures/BaseCommandInteraction.js @@ -16,13 +16,6 @@ class BaseCommandInteraction extends Interaction { constructor(client, data) { super(client, data); - /** - * The channel this interaction was sent in - * @type {?TextBasedChannels} - * @name BaseCommandInteraction#channel - * @readonly - */ - /** * The id of the channel this interaction was sent in * @type {Snowflake} @@ -138,6 +131,7 @@ class BaseCommandInteraction extends Interaction { * @typedef {Object} CommandInteractionOption * @property {string} name The name of the option * @property {ApplicationCommandOptionType} type The type of the option + * @property {boolean} [autocomplete] Whether the option is an autocomplete option * @property {string|number|boolean} [value] The value of the option * @property {CommandInteractionOption[]} [options] Additional options if this option is a * subcommand (group) diff --git a/src/structures/CommandInteractionOptionResolver.js b/src/structures/CommandInteractionOptionResolver.js index e74613f45a76..c83fd8e2f00e 100644 --- a/src/structures/CommandInteractionOptionResolver.js +++ b/src/structures/CommandInteractionOptionResolver.js @@ -239,6 +239,18 @@ class CommandInteractionOptionResolver { const option = this._getTypedOption(name, '_MESSAGE', ['message'], required); return option?.message ?? null; } + + /** + * Gets the focused option. + * @param {boolean} [getFull=false] Whether to get the full option object + * @returns {string|number|ApplicationCommandOptionChoice} + * The value of the option, or the whole option if getFull is true + */ + getFocused(getFull = false) { + const focusedOption = this._hoistedOptions.find(option => option.focused); + if (!focusedOption) throw new TypeError('AUTOCOMPLETE_INTERACTION_OPTION_NO_FOCUSED_OPTION'); + return getFull ? focusedOption : focusedOption.value; + } } module.exports = CommandInteractionOptionResolver; diff --git a/src/structures/Interaction.js b/src/structures/Interaction.js index fa39123cc942..99c6441fa0de 100644 --- a/src/structures/Interaction.js +++ b/src/structures/Interaction.js @@ -160,6 +160,14 @@ class Interaction extends Base { return InteractionTypes[this.type] === InteractionTypes.APPLICATION_COMMAND && typeof this.targetId !== 'undefined'; } + /** + * Indicates whether this interaction is an {@link AutocompleteInteraction} + * @returns {boolean} + */ + isAutocomplete() { + return InteractionTypes[this.type] === InteractionTypes.APPLICATION_COMMAND_AUTOCOMPLETE; + } + /** * Indicates whether this interaction is a {@link MessageComponentInteraction}. * @returns {boolean} diff --git a/src/util/Constants.js b/src/util/Constants.js index ee810341e93e..47b8debe57c7 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -971,7 +971,13 @@ exports.ApplicationCommandPermissionTypes = createEnum([null, 'ROLE', 'USER']); * @typedef {string} InteractionType * @see {@link https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-type} */ -exports.InteractionTypes = createEnum([null, 'PING', 'APPLICATION_COMMAND', 'MESSAGE_COMPONENT']); +exports.InteractionTypes = createEnum([ + null, + 'PING', + 'APPLICATION_COMMAND', + 'MESSAGE_COMPONENT', + 'APPLICATION_COMMAND_AUTOCOMPLETE', +]); /** * The type of an interaction response: @@ -992,6 +998,7 @@ exports.InteractionResponseTypes = createEnum([ 'DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE', 'DEFERRED_MESSAGE_UPDATE', 'UPDATE_MESSAGE', + 'APPLICATION_COMMAND_AUTOCOMPLETE_RESULT', ]); /* eslint-enable max-len */ diff --git a/typings/enums.d.ts b/typings/enums.d.ts index 79df75adff2f..4332983a55ec 100644 --- a/typings/enums.d.ts +++ b/typings/enums.d.ts @@ -92,12 +92,14 @@ export const enum InteractionResponseTypes { DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5, DEFERRED_MESSAGE_UPDATE = 6, UPDATE_MESSAGE = 7, + APPLICATION_COMMAND_AUTOCOMPLETE_RESULT = 8, } export const enum InteractionTypes { PING = 1, APPLICATION_COMMAND = 2, MESSAGE_COMPONENT = 3, + APPLICATION_COMMAND_AUTOCOMPLETE = 4, } export const enum InviteTargetType { diff --git a/typings/index.d.ts b/typings/index.d.ts index a450f298d4d4..ebd29199aef8 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -277,8 +277,20 @@ export type GuildCacheMessage = CacheTypeReducer< >; export abstract class BaseCommandInteraction extends Interaction { - public options: CommandInteractionOptionResolver; public readonly command: ApplicationCommand | ApplicationCommand<{ guild: GuildResolvable }> | null; + public options: Omit< + CommandInteractionOptionResolver, + | 'getFocused' + | 'getMentionable' + | 'getRole' + | 'getNumber' + | 'getInteger' + | 'getString' + | 'getChannel' + | 'getBoolean' + | 'getSubcommandGroup' + | 'getSubcommand' + >; public channelId: Snowflake; public commandId: Snowflake; public commandName: string; @@ -589,10 +601,60 @@ export abstract class Collector extends EventEmi public once(event: 'end', listener: (collected: Collection, reason: string) => Awaitable): this; } +export interface ApplicationCommandInteractionOptionResolver + extends BaseCommandInteractionOptionResolver { + getSubcommand(required?: true): string; + getSubcommand(required: boolean): string | null; + getSubcommandGroup(required?: true): string; + getSubcommandGroup(required: boolean): string | null; + getBoolean(name: string, required: true): boolean; + getBoolean(name: string, required?: boolean): boolean | null; + getChannel(name: string, required: true): NonNullable['channel']>; + getChannel(name: string, required?: boolean): NonNullable['channel']> | null; + getString(name: string, required: true): string; + getString(name: string, required?: boolean): string | null; + getInteger(name: string, required: true): number; + getInteger(name: string, required?: boolean): number | null; + getNumber(name: string, required: true): number; + getNumber(name: string, required?: boolean): number | null; + getUser(name: string, required: true): NonNullable['user']>; + getUser(name: string, required?: boolean): NonNullable['user']> | null; + getMember(name: string, required: true): NonNullable['member']>; + getMember(name: string, required?: boolean): NonNullable['member']> | null; + getRole(name: string, required: true): NonNullable['role']>; + getRole(name: string, required?: boolean): NonNullable['role']> | null; + getMentionable( + name: string, + required: true, + ): NonNullable['member' | 'role' | 'user']>; + getMentionable( + name: string, + required?: boolean, + ): NonNullable['member' | 'role' | 'user']> | null; +} + export class CommandInteraction extends BaseCommandInteraction { + public options: Omit, 'getMessage' | 'getFocused'>; + public inGuild(): this is CommandInteraction<'present'> & this; + public inCachedGuild(): this is CommandInteraction<'cached'> & this; + public inRawGuild(): this is CommandInteraction<'raw'> & this; public toString(): string; } +export class AutocompleteInteraction extends Interaction { + public readonly command: ApplicationCommand | ApplicationCommand<{ guild: GuildResolvable }> | null; + public channelId: Snowflake; + public commandId: Snowflake; + public commandName: string; + public responded: boolean; + public options: Omit, 'getMessage'>; + public inGuild(): this is CommandInteraction<'present'> & this; + public inCachedGuild(): this is CommandInteraction<'cached'> & this; + public inRawGuild(): this is CommandInteraction<'raw'> & this; + private transformOption(option: APIApplicationCommandOption): CommandInteractionOption; + public respond(options: ApplicationCommandOptionChoice[]): Promise; +} + export class CommandInteractionOptionResolver { private constructor(client: Client, options: CommandInteractionOption[], resolved: CommandInteractionResolvedData); public readonly client: Client; @@ -647,7 +709,10 @@ export class CommandInteractionOptionResolver['member' | 'role' | 'user']> | null; public getMessage(name: string, required: true): NonNullable['message']>; public getMessage(name: string, required?: boolean): NonNullable['message']> | null; + public getFocused(getFull: true): ApplicationCommandOptionChoice; + public getFocused(getFull?: boolean): string | number; } + export class ContextMenuInteraction extends BaseCommandInteraction { public targetId: Snowflake; public targetType: Exclude; @@ -1095,6 +1160,7 @@ export class Interaction extends Base { public isApplicationCommand(): this is BaseCommandInteraction; public isButton(): this is ButtonInteraction; public isCommand(): this is CommandInteraction; + public isAutocomplete(): this is AutocompleteInteraction; public isContextMenu(): this is ContextMenuInteraction; public isMessageComponent(): this is MessageComponentInteraction; public isSelectMenu(): this is SelectMenuInteraction; @@ -3187,6 +3253,7 @@ export interface BaseApplicationCommandOptionsData { name: string; description: string; required?: boolean; + autocomplete?: boolean; } export interface UserApplicationCommandData extends BaseApplicationCommandData { @@ -3643,6 +3710,8 @@ export interface CommandInteractionOption name: string; type: ApplicationCommandOptionType; value?: string | number | boolean; + focused?: boolean; + autocomplete?: boolean; options?: CommandInteractionOption[]; user?: User; member?: CacheTypeReducer; diff --git a/typings/tests.ts b/typings/tests.ts index f9c6bd9bcd6d..2ce3fa4c092b 100644 --- a/typings/tests.ts +++ b/typings/tests.ts @@ -19,7 +19,9 @@ import { ApplicationCommandResolvable, ApplicationCommandSubCommandData, ApplicationCommandSubGroupData, + BaseCommandInteraction, ButtonInteraction, + CacheType, CategoryChannel, Client, ClientApplication, @@ -922,7 +924,7 @@ client.on('interactionCreate', async interaction => { if (interaction.inCachedGuild()) { assertType(interaction); assertType(interaction.guild); - assertType>(interaction); + assertType>(interaction); } else if (interaction.inRawGuild()) { assertType(interaction); assertType(interaction.guild); @@ -994,7 +996,6 @@ client.on('interactionCreate', async interaction => { assertType(interaction.options.getChannel('test', true)); assertType(interaction.options.getRole('test', true)); - assertType(interaction.options.getMessage('test', true)); } else if (interaction.inCachedGuild()) { const msg = await interaction.reply({ fetchReply: true }); const btn = await msg.awaitMessageComponent({ componentType: 'BUTTON' }); @@ -1010,7 +1011,6 @@ client.on('interactionCreate', async interaction => { assertType(interaction.options.getChannel('test', true)); assertType(interaction.options.getRole('test', true)); - assertType(interaction.options.getMessage('test', true)); } else { // @ts-expect-error consumeCachedCommand(interaction); @@ -1023,11 +1023,10 @@ client.on('interactionCreate', async interaction => { interaction.options.getChannel('test', true), ); assertType(interaction.options.getRole('test', true)); - assertType(interaction.options.getMessage('test', true)); } assertType(interaction); - assertType(interaction.options); + assertType, 'getFocused' | 'getMessage'>>(interaction.options); assertType(interaction.options.data); const optionalOption = interaction.options.get('name');