diff --git a/.eslintrc.js b/.eslintrc.js index a10e7427..1dc56697 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -29,6 +29,7 @@ module.exports = { ], 'no-cond-assign': [2, 'except-parens'], 'no-unused-vars': 0, + 'no-dupe-class-members': 0, '@typescript-eslint/no-unused-vars': 1, 'no-empty': [ 'error', diff --git a/src/api.ts b/src/api.ts index e6688f6f..54473dec 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,4 +1,10 @@ -import { ApplicationCommand, BulkUpdateCommand, Endpoints, PartialApplicationCommand } from './constants'; +import { + ApplicationCommand, + BulkUpdateCommand, + Endpoints, + InteractionCallbackResponse, + PartialApplicationCommand +} from './constants'; import { BaseSlashCreator } from './creator'; import type { FileContent } from './rest/requestHandler'; import type { MessageData } from './structures/message'; @@ -169,17 +175,31 @@ export class SlashCreatorAPI { * @param interactionToken The interaction's token. * @param body The body to send. * @param files The files to send. + * @param withResponse Whether to recieve the response of the interaction callback */ + interactionCallback( + interactionID: string, + interactionToken: string, + body: any, + files?: FileContent[], + withResponse?: WithResponse + ): Promise; interactionCallback( interactionID: string, interactionToken: string, body: any, - files?: FileContent[] - ): Promise { + files?: FileContent[], + withResponse = false + ): Promise { return this._creator.requestHandler.request( 'POST', Endpoints.INTERACTION_CALLBACK(interactionID, interactionToken), - { auth: false, body, files } + { + auth: false, + body, + files, + query: { with_response: withResponse } + } ); } } diff --git a/src/constants.ts b/src/constants.ts index cae6edfe..cbafb343 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,7 +2,7 @@ import type { SlashCommand } from './command'; import type { CommandContext } from './structures/interfaces/commandContext'; import type { RespondFunction, TransformedRequest } from './server'; import type { ComponentContext } from './structures/interfaces/componentContext'; -import type { MessageData } from './structures/message'; +import type { Message, MessageData } from './structures/message'; import type { AutocompleteContext } from './structures/interfaces/autocompleteContext'; import type { ModalInteractionContext } from './structures/interfaces/modalInteractionContext'; import type { BaseSlashCreator } from './creator'; @@ -575,7 +575,7 @@ export interface AppEntitlement { guild_id?: string; application_id: string; type: EntitlementType; - consumed: false; + consumed: boolean; starts_at?: string; ends_at?: string; } @@ -640,6 +640,25 @@ export interface AutocompleteData { */ export type CommandAutocompleteRequestData = DMCommandAutocompleteRequestData | GuildCommandAutocompleteRequestData; +/* @private **/ +export interface InteractionCallbackResponse { + interaction: { + id: string; + type: InteractionType; + activity_instance_id?: string; + response_message_id?: string; + response_message_loading?: boolean; + response_message_ephemeral?: boolean; + }; + resource?: { + type: InteractionResponseType; + activity_instance?: { + id: string; + }; + message?: MessageData; + }; +} + /** @private */ export interface ResolvedMemberData { avatar?: string; @@ -850,6 +869,36 @@ export interface CommandSubcommandOption { options?: AnyCommandOption[]; } +/** The response to the initial interaction callback. */ +export interface InitialInteractionResponse { + /** The interaction associated with this response */ + interaction: { + /** ID of the interaction */ + id: string; + /** The type of the interaction */ + type: InteractionType; + /** The instance ID of the activity if one was launched/joined */ + activityInstanceID?: string; + /** The ID of the message created by the interaction */ + responseMessageID?: string; + /** Whether or not the message is in a loading state */ + responseMessageLoading?: boolean; + /** Whether or not the response message is ephemeral */ + responseMessageEphemeral?: boolean; + }; + /** The resource created by this interaction response. */ + resource?: { + /** The type of the interaction response */ + type: InteractionResponseType; + /** The activity instance launched by this interaction */ + activityInstance?: { + id: string; + }; + /** The message created by this interaction */ + message?: Message; + }; +} + /** The types of components available. */ export enum ComponentType { /** A row of components. */ diff --git a/src/creator.ts b/src/creator.ts index 97df39fd..199b64e2 100644 --- a/src/creator.ts +++ b/src/creator.ts @@ -544,7 +544,7 @@ export class BaseSlashCreator extends (EventEmitter as any as new () => TypedEve if (!verified) { this.emit('debug', 'A request failed to be verified'); this.emit('unverifiedRequest', treq); - return respond({ + return void respond({ status: 401, body: 'Invalid signature' }); @@ -782,9 +782,8 @@ export class BaseSlashCreator extends (EventEmitter as any as new () => TypedEve } private _createGatewayRespond(interactionID: string, token: string): RespondFunction { - return async (response: Response) => { - await this.api.interactionCallback(interactionID, token, response.body, response.files); - }; + return async (response: Response) => + this.api.interactionCallback(interactionID, token, response.body, response.files, true); } } diff --git a/src/server.ts b/src/server.ts index cefff3b5..b72ef08d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { AnyRequestData } from './constants'; +import { AnyRequestData, InteractionCallbackResponse } from './constants'; import type { FileContent } from './rest/requestHandler'; /** @@ -84,7 +84,7 @@ export interface Response { * The response function for a {@link Server}. * @private */ -export type RespondFunction = (response: Response) => Promise; +export type RespondFunction = (response: Response) => Promise; /** * The handler for pushing requests to a {@link SlashCreator}. diff --git a/src/structures/interfaces/autocompleteContext.ts b/src/structures/interfaces/autocompleteContext.ts index 083c074b..76467018 100644 --- a/src/structures/interfaces/autocompleteContext.ts +++ b/src/structures/interfaces/autocompleteContext.ts @@ -1,6 +1,12 @@ -import { AnyCommandOption, CommandAutocompleteRequestData, InteractionResponseType } from '../../constants'; +import { + AnyCommandOption, + CommandAutocompleteRequestData, + InitialInteractionResponse, + InteractionResponseType +} from '../../constants'; import { BaseSlashCreator } from '../../creator'; import { RespondFunction } from '../../server'; +import { convertCallbackResponse } from '../../util'; import { BaseInteractionContext } from './baseInteraction'; import { CommandContext } from './commandContext'; @@ -44,19 +50,20 @@ export class AutocompleteContext extends Ba /** * Sends the results of an autocomplete interaction. * @param choices The choices to display + * @returns boolean or a {@link InitialInteractionResponse} if the response passed */ - async sendResults(choices: AutocompleteChoice[]): Promise { + async sendResults(choices: AutocompleteChoice[]): Promise { if (this.responded) return false; this.responded = true; - await this._respond({ + const response = await this._respond({ status: 200, body: { type: InteractionResponseType.APPLICATION_COMMAND_AUTOCOMPLETE_RESULT, data: { choices } } }); - return true; + return response ? convertCallbackResponse(response, this) : true; } /** @private */ diff --git a/src/structures/interfaces/componentContext.ts b/src/structures/interfaces/componentContext.ts index 3a08d41d..41200389 100644 --- a/src/structures/interfaces/componentContext.ts +++ b/src/structures/interfaces/componentContext.ts @@ -1,9 +1,14 @@ -import { ComponentType, InteractionResponseType, MessageComponentRequestData } from '../../constants'; +import { + ComponentType, + InitialInteractionResponse, + InteractionResponseType, + MessageComponentRequestData +} from '../../constants'; import { EditMessageOptions } from './messageInteraction'; import { BaseSlashCreator } from '../../creator'; import { RespondFunction } from '../../server'; import { Message } from '../message'; -import { formatAllowedMentions, FormattedAllowedMentions } from '../../util'; +import { convertCallbackResponse, formatAllowedMentions, FormattedAllowedMentions } from '../../util'; import { ModalSendableContext } from './modalSendableContext'; /** Represents an interaction context from a message component. */ @@ -48,19 +53,19 @@ export class ComponentContext extends Modal /** * Acknowledges the interaction without replying. - * @returns Whether the acknowledgement passed passed + * @returns Whether the acknowledgement passed or the callback response if available */ - async acknowledge(): Promise { + async acknowledge(): Promise { if (!this.initiallyResponded) { this.initiallyResponded = true; clearTimeout(this._timeout); - await this._respond({ + const response = await this._respond({ status: 200, body: { type: InteractionResponseType.DEFERRED_UPDATE_MESSAGE } }); - return true; + return response ? convertCallbackResponse(response, this) : true; } return false; @@ -68,10 +73,11 @@ export class ComponentContext extends Modal /** * Edits the message that the component interaction came from. - * This will return a boolean if it's an initial response, otherwise a {@link Message} will be returned. + * This will return `true` or a {@link InitialInteractionResponse} if it's an initial response, otherwise a {@link Message} will be returned. * @param content The content of the message + * @returns `true` or a {@link InitialInteractionResponse} if the initial response passed, otherwise a {@link Message} of the parent message. */ - async editParent(content: string | EditMessageOptions): Promise { + async editParent(content: string | EditMessageOptions): Promise { if (this.expired) throw new Error('This interaction has expired'); const options = typeof content === 'string' ? { content } : content; @@ -86,7 +92,7 @@ export class ComponentContext extends Modal if (!this.initiallyResponded) { this.initiallyResponded = true; clearTimeout(this._timeout); - await this._respond({ + const response = await this._respond({ status: 200, body: { type: InteractionResponseType.UPDATE_MESSAGE, @@ -99,7 +105,7 @@ export class ComponentContext extends Modal }, files: options.files }); - return true; + return response ? convertCallbackResponse(response, this) : true; } else return this.edit(this.message.id, content); } } diff --git a/src/structures/interfaces/messageInteraction.ts b/src/structures/interfaces/messageInteraction.ts index ab9ca723..86a53b0f 100644 --- a/src/structures/interfaces/messageInteraction.ts +++ b/src/structures/interfaces/messageInteraction.ts @@ -1,7 +1,17 @@ -import { AnyComponent, InteractionResponseFlags, InteractionResponseType } from '../../constants'; +import { + AnyComponent, + InitialInteractionResponse, + InteractionResponseFlags, + InteractionResponseType +} from '../../constants'; import { BaseSlashCreator, ComponentRegisterCallback } from '../../creator'; import { RespondFunction } from '../../server'; -import { formatAllowedMentions, FormattedAllowedMentions, MessageAllowedMentions } from '../../util'; +import { + convertCallbackResponse, + formatAllowedMentions, + FormattedAllowedMentions, + MessageAllowedMentions +} from '../../util'; import { Message, MessageEmbedOptions } from '../message'; import { BaseInteractionContext } from './baseInteraction'; @@ -49,13 +59,14 @@ export class MessageInteractionContext< /** * Sends a message, if it already made an initial response, this will create a follow-up message. - * IF the context has created a deferred message, it will edit that deferred message, + * If the context has created a deferred message, it will edit that deferred message, * and future calls to this function create follow ups. - * This will return a boolean if it's an initial response, otherwise a {@link Message} will be returned. + * This will return `true` or a {@link InitialInteractionResponse} if it's an initial response, otherwise a {@link Message} will be returned. * Note that when making a follow-up message, the `ephemeral` option is ignored. * @param content The content of the message + * @returns `true` or a {@link InitialInteractionResponse} if the initial response passed, otherwise a {@link Message} of the follow-up message. */ - async send(content: string | MessageOptions): Promise { + async send(content: string | MessageOptions): Promise { if (this.expired) throw new Error('This interaction has expired'); const options = typeof content === 'string' ? { content } : content; @@ -70,7 +81,7 @@ export class MessageInteractionContext< if (!this.initiallyResponded) { this.initiallyResponded = true; clearTimeout(this._timeout); - await this._respond({ + const response = await this._respond({ status: 200, body: { type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, @@ -86,7 +97,7 @@ export class MessageInteractionContext< }, files: options.files }); - return true; + return response ? convertCallbackResponse(response, this) : true; } else if (this.initiallyResponded && this.deferred) return this.editOriginal(content); else return this.sendFollowUp(options); } @@ -183,21 +194,22 @@ export class MessageInteractionContext< this.interactionToken, messageID ); - if (!messageID || messageID === '@original') this.messageID = undefined; + + if (!messageID || messageID === '@original' || messageID === this.messageID) this.messageID = undefined; } /** * Creates a deferred message. To users, this will show as * "Bot is thinking..." until the deferred message is edited. * @param ephemeral Whether to make the deferred message ephemeral. - * @returns Whether the deferred message passed + * @returns Whether the deferred message passed or the callback response if available */ - async defer(ephemeral = false): Promise { + async defer(ephemeral = false): Promise { if (!this.initiallyResponded && !this.deferred) { this.initiallyResponded = true; this.deferred = true; clearTimeout(this._timeout); - await this._respond({ + const response = await this._respond({ status: 200, body: { type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, @@ -206,7 +218,7 @@ export class MessageInteractionContext< } } }); - return true; + return response ? convertCallbackResponse(response, this) : true; } return false; @@ -214,22 +226,22 @@ export class MessageInteractionContext< /** * Creates a message that prompts the user for a premium subscription. - * @returns Whether the message passed + * @returns Whether the message passed or the callback response if available * @deprecated Use `ComponentButtonPremium` instead. */ - async promptPremium(): Promise { + async promptPremium(): Promise { if (!this.initiallyResponded && !this.deferred) { this.initiallyResponded = true; this.deferred = true; clearTimeout(this._timeout); - await this._respond({ + const response = await this._respond({ status: 200, body: { type: InteractionResponseType.PREMIUM_REQUIRED, data: {} } }); - return true; + return response ? convertCallbackResponse(response, this) : true; } return false; @@ -237,21 +249,21 @@ export class MessageInteractionContext< /** * Launches the activity this app is associated with. - * @returns Whether the message passed + * @returns Whether the message passed or the callback response if available */ - async launchActivity(): Promise { + async launchActivity(): Promise { if (!this.initiallyResponded && !this.deferred) { this.initiallyResponded = true; this.deferred = true; clearTimeout(this._timeout); - await this._respond({ + const response = await this._respond({ status: 200, body: { type: InteractionResponseType.LAUNCH_ACTIVITY, data: {} } }); - return true; + return response ? convertCallbackResponse(response, this) : true; } return false; diff --git a/src/structures/interfaces/modalInteractionContext.ts b/src/structures/interfaces/modalInteractionContext.ts index 1aedc3fc..dd2695ba 100644 --- a/src/structures/interfaces/modalInteractionContext.ts +++ b/src/structures/interfaces/modalInteractionContext.ts @@ -2,12 +2,13 @@ import { AnyComponent, ComponentTextInput, ComponentType, + InitialInteractionResponse, InteractionResponseType, ModalSubmitRequestData } from '../../constants'; import { BaseSlashCreator } from '../../creator'; import { RespondFunction } from '../../server'; -import { formatAllowedMentions, FormattedAllowedMentions } from '../../util'; +import { convertCallbackResponse, formatAllowedMentions, FormattedAllowedMentions } from '../../util'; import { Message } from '../message'; import { EditMessageOptions, MessageInteractionContext } from './messageInteraction'; @@ -71,30 +72,31 @@ export class ModalInteractionContext< /** * Acknowledges the interaction without replying. - * @returns Whether the acknowledgement passed + * @returns Whether the acknowledgement passed or the callback response if available */ - async acknowledge(): Promise { + async acknowledge(): Promise { if (!this.initiallyResponded) { this.initiallyResponded = true; clearTimeout(this._timeout); - await this._respond({ + const response = await this._respond({ status: 200, body: { type: InteractionResponseType.DEFERRED_UPDATE_MESSAGE } }); - return true; + return response ? convertCallbackResponse(response, this) : true; } return false; } - /* + /** * Edits the message that the component interaction came from. - * This will return a boolean if it's an initial response, otherwise a {@link Message} will be returned. + * This will return `true` or a {@link InitialInteractionResponse} if it's an initial response, otherwise a {@link Message} will be returned. * @param content The content of the message + * @returns `true` or a {@link InitialInteractionResponse} if the initial response passed, otherwise a {@link Message} of the parent message. */ - async editParent(content: string | EditMessageOptions): Promise { + async editParent(content: string | EditMessageOptions): Promise { if (this.expired) throw new Error('This interaction has expired'); if (!this.message) throw new Error('This interaction has no message'); @@ -110,7 +112,7 @@ export class ModalInteractionContext< if (!this.initiallyResponded) { this.initiallyResponded = true; clearTimeout(this._timeout); - await this._respond({ + const response = await this._respond({ status: 200, body: { type: InteractionResponseType.UPDATE_MESSAGE, @@ -123,7 +125,7 @@ export class ModalInteractionContext< }, files: options.files }); - return true; + return response ? convertCallbackResponse(response, this) : true; } else return this.edit(this.message.id, content); } } diff --git a/src/structures/interfaces/modalSendableContext.ts b/src/structures/interfaces/modalSendableContext.ts index 8c1588e6..ee21a658 100644 --- a/src/structures/interfaces/modalSendableContext.ts +++ b/src/structures/interfaces/modalSendableContext.ts @@ -1,7 +1,7 @@ -import { AnyComponent, InteractionResponseType } from '../../constants'; +import { AnyComponent, InitialInteractionResponse, InteractionResponseType } from '../../constants'; import { ModalRegisterCallback, BaseSlashCreator } from '../../creator'; import { RespondFunction } from '../../server'; -import { generateID } from '../../util'; +import { convertCallbackResponse, generateID } from '../../util'; import { MessageInteractionContext } from './messageInteraction'; /** Represents an interaction that can send modals. */ @@ -18,9 +18,15 @@ export class ModalSendableContext< * If a callback is defined, a custom ID will be generated if not defined. * @param options The message options * @param callback The callback of the modal - * @returns The custom ID of the modal + * @returns The custom ID of the modal and the callback response if available */ - async sendModal(options: ModalOptions, callback?: ModalRegisterCallback): Promise { + async sendModal( + options: ModalOptions, + callback?: ModalRegisterCallback + ): Promise<{ + customID: string; + response: InitialInteractionResponse | null; + }> { if (this.expired) throw new Error('This interaction has expired'); if (this.initiallyResponded) throw new Error('This interaction has already responded.'); @@ -38,7 +44,7 @@ export class ModalSendableContext< this.initiallyResponded = true; clearTimeout(this._timeout); - await this._respond({ + const response = await this._respond({ status: 200, body: { type: InteractionResponseType.MODAL, @@ -46,7 +52,10 @@ export class ModalSendableContext< } }); - return options.custom_id!; + return { + customID: options.custom_id!, + response: response ? convertCallbackResponse(response, this) : null + }; } } diff --git a/src/util.ts b/src/util.ts index d0d8fca5..297bbc17 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,4 +1,11 @@ -import { ApplicationCommandOption, CommandOptionType } from './constants'; +import { + ApplicationCommandOption, + CommandOptionType, + InitialInteractionResponse, + InteractionCallbackResponse +} from './constants'; +import { MessageInteractionContext } from './structures/interfaces/messageInteraction'; +import { BaseInteractionContext, Message } from './web'; export function formatAllowedMentions( allowed: MessageAllowedMentions | FormattedAllowedMentions, @@ -103,6 +110,41 @@ export function generateID() { return (Date.now() + Math.round(Math.random() * 1000)).toString(36); } +export function convertCallbackResponse( + response: InteractionCallbackResponse, + ctx: BaseInteractionContext +): InitialInteractionResponse { + const result: InitialInteractionResponse = { + interaction: { + id: response.interaction.id, + type: response.interaction.type, + activityInstanceID: response.interaction.activity_instance_id, + responseMessageID: response.interaction.response_message_id, + responseMessageLoading: response.interaction.response_message_loading, + responseMessageEphemeral: response.interaction.response_message_ephemeral + } + }; + + const isMessageCtx = ctx instanceof MessageInteractionContext; + + if (response.interaction.response_message_id && isMessageCtx) + ctx.messageID = response.interaction.response_message_id; + + if (response.resource) { + result.resource = { + type: response.resource.type + }; + + if (response.resource.activity_instance) + result.resource.activityInstance = { id: response.resource.activity_instance.id }; + + if (response.resource.message) + result.resource.message = new Message(response.resource.message, ctx.creator, isMessageCtx ? ctx : undefined); + } + + return result; +} + /** * Calculates the timestamp in milliseconds associated with a Discord ID/snowflake * @param id The ID of a structure