diff --git a/packages/discord.js/src/index.js b/packages/discord.js/src/index.js index cdedc8aa45c0..aad3b506f36b 100644 --- a/packages/discord.js/src/index.js +++ b/packages/discord.js/src/index.js @@ -142,6 +142,9 @@ exports.GuildScheduledEvent = require('./structures/GuildScheduledEvent').GuildS exports.GuildTemplate = require('./structures/GuildTemplate'); exports.Integration = require('./structures/Integration'); exports.IntegrationApplication = require('./structures/IntegrationApplication'); +exports.InteractionCallback = require('./structures/InteractionCallback'); +exports.InteractionCallbackResource = require('./structures/InteractionCallbackResource'); +exports.InteractionCallbackResponse = require('./structures/InteractionCallbackResponse'); exports.BaseInteraction = require('./structures/BaseInteraction'); exports.InteractionCollector = require('./structures/InteractionCollector'); exports.InteractionResponse = require('./structures/InteractionResponse'); diff --git a/packages/discord.js/src/structures/InteractionCallback.js b/packages/discord.js/src/structures/InteractionCallback.js new file mode 100644 index 000000000000..e2d2cf9b3c5b --- /dev/null +++ b/packages/discord.js/src/structures/InteractionCallback.js @@ -0,0 +1,92 @@ +'use strict'; + +const { DiscordSnowflake } = require('@sapphire/snowflake'); + +/** + * Represents an interaction callback response from Discord + */ +class InteractionCallback { + constructor(client, data) { + /** + * The client that instantiated this. + * @name InteractionCallback#client + * @type {Client} + * @readonly + */ + Object.defineProperty(this, 'client', { value: client }); + + /** + * The id of the original interaction response + * @type {Snowflake} + */ + this.id = data.id; + + /** + * The type of the original interaction + * @type {InteractionType} + */ + this.type = data.type; + + /** + * The instance id of the Activity if one was launched or joined + * @type {?string} + */ + this.activityInstanceId = data.activity_instance_id ?? null; + + /** + * The id of the message that was created by the interaction + * @type {?Snowflake} + */ + this.responseMessageId = data.response_message_id ?? null; + + /** + * Whether the message is in a loading state + * @type {?boolean} + */ + this.responseMessageLoading = data.response_message_loading ?? null; + + /** + * Whether the response message was ephemeral + * @type {?boolean} + */ + this.responseMessageEphemeral = data.response_message_ephemeral ?? null; + } + + /** + * The timestamp the original interaction was created at + * @type {number} + * @readonly + */ + get createdTimestamp() { + return DiscordSnowflake.timestampFrom(this.id); + } + + /** + * The time the original interaction was created at + * @type {Date} + * @readonly + */ + get createdAt() { + return new Date(this.createdTimestamp); + } + + /** + * The channel the original interaction was sent in + * @type {?TextBasedChannels} + * @readonly + */ + get channel() { + return this.client.channels.cache.get(this.channelId) ?? null; + } + + /** + * The guild the original interaction was sent in + * @type {?Guild} + * @readonly + */ + get guild() { + return this.client.guilds.cache.get(this.guildId) ?? null; + } +} + +module.exports = InteractionCallback; diff --git a/packages/discord.js/src/structures/InteractionCallbackResource.js b/packages/discord.js/src/structures/InteractionCallbackResource.js new file mode 100644 index 000000000000..ffb088d8100e --- /dev/null +++ b/packages/discord.js/src/structures/InteractionCallbackResource.js @@ -0,0 +1,52 @@ +'use strict'; + +const { lazy } = require('@discordjs/util'); + +const getMessage = lazy(() => require('./Message').Message); + +/** + * Represents the resource that was created by the interaction response. + */ +class InteractionCallbackResource { + constructor(client, data) { + /** + * The client that instantiated this + * @name InteractionCallbackResource#client + * @type {Client} + * @readonly + */ + Object.defineProperty(this, 'client', { value: client }); + + /** + * The interaction callback type + * @type {InteractionResponseType} + */ + this.type = data.type; + + /** + * The Activity launched by an interaction + * @typedef {Object} ActivityInstance + * @property {string} id The instance id of the Activity + */ + + /** + * Represents the Activity launched by this interaction + * @type {?ActivityInstance} + */ + this.activityInstance = data.activity_instance ?? null; + + if ('message' in data) { + /** + * The message created by the interaction + * @type {?Message} + */ + this.message = + this.client.channels.cache.get(data.message.channel_id)?.messages._add(data.message) ?? + new (getMessage())(client, data.message); + } else { + this.message = null; + } + } +} + +module.exports = InteractionCallbackResource; diff --git a/packages/discord.js/src/structures/InteractionCallbackResponse.js b/packages/discord.js/src/structures/InteractionCallbackResponse.js new file mode 100644 index 000000000000..c114648398ae --- /dev/null +++ b/packages/discord.js/src/structures/InteractionCallbackResponse.js @@ -0,0 +1,33 @@ +'use strict'; + +const InteractionCallback = require('./InteractionCallback'); +const InteractionCallbackResource = require('./InteractionCallbackResource'); + +/** + * Represents an interaction's response + */ +class InteractionCallbackResponse { + constructor(client, data) { + /** + * The client that instantiated this + * @name InteractionCallbackResponse#client + * @type {Client} + * @readonly + */ + Object.defineProperty(this, 'client', { value: client }); + + /** + * The interaction object associated with the interaction callback response + * @type {InteractionCallback} + */ + this.interaction = new InteractionCallback(client, data.interaction); + + /** + * The resource that was created by the interaction response + * @type {?InteractionCallbackResource} + */ + this.resource = data.resource ? new InteractionCallbackResource(client, data.resource) : null; + } +} + +module.exports = InteractionCallbackResponse; diff --git a/packages/discord.js/src/structures/interfaces/InteractionResponses.js b/packages/discord.js/src/structures/interfaces/InteractionResponses.js index e448e19fd17e..47167097ac0e 100644 --- a/packages/discord.js/src/structures/interfaces/InteractionResponses.js +++ b/packages/discord.js/src/structures/interfaces/InteractionResponses.js @@ -1,8 +1,10 @@ 'use strict'; +const { makeURLSearchParams } = require('@discordjs/rest'); const { isJSONEncodable } = require('@discordjs/util'); const { InteractionResponseType, MessageFlags, Routes, InteractionType } = require('discord-api-types/v10'); const { DiscordjsError, ErrorCodes } = require('../../errors'); +const InteractionCallbackResponse = require('../InteractionCallbackResponse'); const InteractionCollector = require('../InteractionCollector'); const InteractionResponse = require('../InteractionResponse'); const MessagePayload = require('../MessagePayload'); @@ -19,9 +21,15 @@ const MessagePayload = require('../MessagePayload'); * @interface */ class InteractionResponses { + /** + * Shared options for responses to a {@link BaseInteraction}. + * @typedef {Object} SharedInteractionResponseOptions + * @property {boolean} [withResponse] Whether to include an {@link InteractionCallbackResponse} as the response + */ + /** * Options for deferring the reply to an {@link BaseInteraction}. - * @typedef {Object} InteractionDeferReplyOptions + * @typedef {SharedInteractionResponseOptions} InteractionDeferReplyOptions * @property {MessageFlagsResolvable} [flags] Flags for the reply. * Only `MessageFlags.Ephemeral` can be set. * @property {boolean} [fetchReply] Whether to fetch the reply @@ -29,13 +37,13 @@ class InteractionResponses { /** * Options for deferring and updating the reply to a {@link MessageComponentInteraction}. - * @typedef {Object} InteractionDeferUpdateOptions + * @typedef {SharedInteractionResponseOptions} InteractionDeferUpdateOptions * @property {boolean} [fetchReply] Whether to fetch the reply */ /** * Options for a reply to a {@link BaseInteraction}. - * @typedef {BaseMessageOptionsWithPoll} InteractionReplyOptions + * @typedef {BaseMessageOptionsWithPoll|SharedInteractionResponseOptions} InteractionReplyOptions * @property {boolean} [tts=false] Whether the message should be spoken aloud * @property {boolean} [fetchReply] Whether to fetch the reply * @property {MessageFlagsResolvable} [flags] Which flags to set for the message. @@ -45,14 +53,19 @@ class InteractionResponses { /** * Options for updating the message received from a {@link MessageComponentInteraction}. - * @typedef {MessageEditOptions} InteractionUpdateOptions + * @typedef {MessageEditOptions|SharedInteractionResponseOptions} InteractionUpdateOptions * @property {boolean} [fetchReply] Whether to fetch the reply */ + /** + * Options for showing a modal in response to a {@link BaseInteraction} + * @typedef {SharedInteractionResponseOptions} ShowModalOptions + */ + /** * Defers the reply to this interaction. * @param {InteractionDeferReplyOptions} [options] Options for deferring the reply to this interaction - * @returns {Promise} + * @returns {Promise} * @example * // Defer the reply to this interaction * interaction.deferReply() @@ -67,7 +80,7 @@ class InteractionResponses { async deferReply(options = {}) { if (this.deferred || this.replied) throw new DiscordjsError(ErrorCodes.InteractionAlreadyReplied); - await this.client.rest.post(Routes.interactionCallback(this.id, this.token), { + const response = await this.client.rest.post(Routes.interactionCallback(this.id, this.token), { body: { type: InteractionResponseType.DeferredChannelMessageWithSource, data: { @@ -75,10 +88,16 @@ class InteractionResponses { }, }, auth: false, + query: makeURLSearchParams({ with_response: options.withResponse ?? false }), }); this.deferred = true; this.ephemeral = Boolean(options.flags & MessageFlags.Ephemeral); + + if (options.withResponse) { + return new InteractionCallbackResponse(this.client, response); + } + return options.fetchReply ? this.fetchReply() : new InteractionResponse(this); } @@ -86,7 +105,7 @@ class InteractionResponses { * Creates a reply to this interaction. * Use the `fetchReply` option to get the bot's reply message. * @param {string|MessagePayload|InteractionReplyOptions} options The options for the reply - * @returns {Promise} + * @returns {Promise} * @example * // Reply to the interaction and fetch the response * interaction.reply({ content: 'Pong!', fetchReply: true }) @@ -109,17 +128,23 @@ class InteractionResponses { const { body: data, files } = await messagePayload.resolveBody().resolveFiles(); - await this.client.rest.post(Routes.interactionCallback(this.id, this.token), { + const response = await this.client.rest.post(Routes.interactionCallback(this.id, this.token), { body: { type: InteractionResponseType.ChannelMessageWithSource, data, }, files, auth: false, + query: makeURLSearchParams({ with_response: options.withResponse ?? false }), }); this.ephemeral = Boolean(options.flags & MessageFlags.Ephemeral); this.replied = true; + + if (options.withResponse) { + return new InteractionCallbackResponse(this.client, response); + } + return options.fetchReply ? this.fetchReply() : new InteractionResponse(this); } @@ -192,7 +217,7 @@ class InteractionResponses { /** * Defers an update to the message to which the component was attached. * @param {InteractionDeferUpdateOptions} [options] Options for deferring the update to this interaction - * @returns {Promise} + * @returns {Promise} * @example * // Defer updating and reset the component's loading state * interaction.deferUpdate() @@ -201,21 +226,26 @@ class InteractionResponses { */ async deferUpdate(options = {}) { if (this.deferred || this.replied) throw new DiscordjsError(ErrorCodes.InteractionAlreadyReplied); - await this.client.rest.post(Routes.interactionCallback(this.id, this.token), { + const response = await this.client.rest.post(Routes.interactionCallback(this.id, this.token), { body: { type: InteractionResponseType.DeferredMessageUpdate, }, auth: false, + query: makeURLSearchParams({ with_response: options.withResponse ?? false }), }); this.deferred = true; + if (options.withResponse) { + return new InteractionCallbackResponse(this.client, response); + } + return options.fetchReply ? this.fetchReply() : new InteractionResponse(this, this.message?.interaction?.id); } /** * Updates the original message of the component on which the interaction was received on. * @param {string|MessagePayload|InteractionUpdateOptions} options The options for the updated message - * @returns {Promise} + * @returns {Promise} * @example * // Remove the components from the message * interaction.update({ @@ -234,34 +264,43 @@ class InteractionResponses { const { body: data, files } = await messagePayload.resolveBody().resolveFiles(); - await this.client.rest.post(Routes.interactionCallback(this.id, this.token), { + const response = await this.client.rest.post(Routes.interactionCallback(this.id, this.token), { body: { type: InteractionResponseType.UpdateMessage, data, }, files, auth: false, + query: makeURLSearchParams({ with_response: options.withResponse ?? false }), }); this.replied = true; + if (options.withResponse) { + return new InteractionCallbackResponse(this.client, response); + } + return options.fetchReply ? this.fetchReply() : new InteractionResponse(this, this.message.interaction?.id); } /** * Shows a modal component * @param {ModalBuilder|ModalComponentData|APIModalInteractionResponseCallbackData} modal The modal to show - * @returns {Promise} + * @param {ShowModalOptions} options The options for sending this interaction response + * @returns {Promise} */ - async showModal(modal) { + async showModal(modal, options = {}) { if (this.deferred || this.replied) throw new DiscordjsError(ErrorCodes.InteractionAlreadyReplied); - await this.client.rest.post(Routes.interactionCallback(this.id, this.token), { + const response = await this.client.rest.post(Routes.interactionCallback(this.id, this.token), { body: { type: InteractionResponseType.Modal, data: isJSONEncodable(modal) ? modal.toJSON() : this.client.options.jsonTransformer(modal), }, auth: false, + query: makeURLSearchParams({ with_response: options.withResponse ?? false }), }); this.replied = true; + + return options.withResponse ? new InteractionCallbackResponse(this.client, response) : undefined; } /** diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index afb73b20a91b..936859c48053 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -166,6 +166,10 @@ import { GuildScheduledEventRecurrenceRuleFrequency, GatewaySendPayload, GatewayDispatchPayload, + RESTPostAPIInteractionCallbackWithResponseResult, + RESTAPIInteractionCallbackObject, + RESTAPIInteractionCallbackResourceObject, + InteractionResponseType, } from 'discord-api-types/v10'; import { ChildProcess } from 'node:child_process'; import { EventEmitter } from 'node:events'; @@ -258,6 +262,10 @@ export class Activity { public toString(): string; } +export interface ActivityInstance { + id: string; +} + export type ActivityFlagsString = keyof typeof ActivityFlags; export interface BaseComponentData { @@ -558,6 +566,9 @@ export abstract class CommandInteraction e public inGuild(): this is CommandInteraction<'raw' | 'cached'>; public inCachedGuild(): this is CommandInteraction<'cached'>; public inRawGuild(): this is CommandInteraction<'raw'>; + public deferReply( + options: InteractionDeferReplyOptions & { withResponse: true }, + ): Promise; public deferReply( options: InteractionDeferReplyOptions & { fetchReply: true }, ): Promise>>; @@ -1973,6 +1984,37 @@ export class BaseInteraction extends Base public isRepliable(): this is RepliableInteraction; } +export class InteractionCallback { + public constructor(client: Client, data: RESTAPIInteractionCallbackObject); + public activityInstanceId: string | null; + public readonly client: Client; + public get channel(): TextBasedChannel | null; + public channelId: Snowflake | null; + public get createdAt(): Date; + public get createdTimestamp(): number; + public get guild(): Guild | null; + public guildId: Snowflake | null; + public id: Snowflake; + public responseMessageEphemeral: boolean | null; + public responseMessageId: Snowflake | null; + public responseMessageLoading: boolean | null; + public type: InteractionType; +} + +export class InteractionCallbackResponse { + private constructor(client: Client, data: RESTPostAPIInteractionCallbackWithResponseResult); + public readonly client: Client; + public interaction: InteractionCallback; + public resource: InteractionCallbackResource | null; +} + +export class InteractionCallbackResource { + private constructor(client: Client, data: RESTAPIInteractionCallbackResourceObject); + public activityInstance: ActivityInstance | null; + public message: Message | null; + public type: InteractionResponseType; +} + export class InteractionCollector extends Collector< Snowflake, Interaction, @@ -6073,7 +6115,7 @@ export interface InteractionCollectorOptions< interactionResponse?: InteractionResponse>; } -export interface InteractionDeferReplyOptions { +export interface InteractionDeferReplyOptions extends SharedInteractionResponseOptions { flags?: BitFieldResolvable< Extract, MessageFlags.Ephemeral | MessageFlags.SuppressEmbeds | MessageFlags.SuppressNotifications @@ -6085,7 +6127,7 @@ export interface InteractionDeferUpdateOptions { fetchReply?: boolean; } -export interface InteractionReplyOptions extends BaseMessageOptionsWithPoll { +export interface InteractionReplyOptions extends BaseMessageOptionsWithPoll, SharedInteractionResponseOptions { tts?: boolean; fetchReply?: boolean; flags?: BitFieldResolvable< @@ -6094,7 +6136,7 @@ export interface InteractionReplyOptions extends BaseMessageOptionsWithPoll { >; } -export interface InteractionUpdateOptions extends MessageEditOptions { +export interface InteractionUpdateOptions extends MessageEditOptions, SharedInteractionResponseOptions { fetchReply?: boolean; } @@ -6606,6 +6648,13 @@ export interface ShardingManagerOptions { execArgv?: readonly string[]; } +export interface SharedInteractionResponseOptions { + withResponse?: boolean; +} + +// tslint:disable-next-line no-empty-interface +export interface ShowModalOptions extends SharedInteractionResponseOptions {} + export { Snowflake }; export type StageInstanceResolvable = StageInstance | Snowflake;