From f1e70f4d45da8f157199f7f776aed1e7abaaef1c Mon Sep 17 00:00:00 2001 From: Almeida Date: Sun, 3 Dec 2023 01:04:57 +0000 Subject: [PATCH 1/4] feat: premium application subscriptions --- packages/core/src/api/index.ts | 5 + packages/core/src/api/interactions.ts | 40 ++++- packages/core/src/api/monetization.ts | 80 +++++++++ packages/core/src/client.ts | 6 + .../src/client/actions/ActionsManager.js | 3 + .../src/client/actions/EntitlementCreate.js | 23 +++ .../src/client/actions/EntitlementDelete.js | 27 +++ .../src/client/actions/EntitlementUpdate.js | 25 +++ .../websocket/handlers/ENTITLEMENT_CREATE.js | 5 + .../websocket/handlers/ENTITLEMENT_DELETE.js | 5 + .../websocket/handlers/ENTITLEMENT_UPDATE.js | 5 + .../src/client/websocket/handlers/index.js | 3 + packages/discord.js/src/errors/ErrorCodes.js | 4 + packages/discord.js/src/errors/Messages.js | 3 + packages/discord.js/src/index.js | 4 + .../src/managers/EntitlementManager.js | 129 ++++++++++++++ .../src/structures/BaseInteraction.js | 10 ++ .../src/structures/ClientApplication.js | 18 ++ .../src/structures/CommandInteraction.js | 1 + .../discord.js/src/structures/Entitlement.js | 164 ++++++++++++++++++ .../structures/MessageComponentInteraction.js | 1 + .../src/structures/ModalSubmitInteraction.js | 1 + packages/discord.js/src/structures/SKU.js | 52 ++++++ .../interfaces/InteractionResponses.js | 17 ++ packages/discord.js/src/util/APITypes.js | 15 ++ packages/discord.js/src/util/Constants.js | 2 + packages/discord.js/src/util/Events.js | 6 + .../discord.js/src/util/SKUFlagsBitField.js | 26 +++ packages/discord.js/src/util/Sweepers.js | 17 ++ packages/discord.js/src/util/Util.js | 13 ++ packages/discord.js/test/monetization.js | 59 +++++++ packages/discord.js/typings/index.d.ts | 98 +++++++++++ packages/discord.js/typings/index.test-d.ts | 54 ++++++ 33 files changed, 913 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/api/monetization.ts create mode 100644 packages/discord.js/src/client/actions/EntitlementCreate.js create mode 100644 packages/discord.js/src/client/actions/EntitlementDelete.js create mode 100644 packages/discord.js/src/client/actions/EntitlementUpdate.js create mode 100644 packages/discord.js/src/client/websocket/handlers/ENTITLEMENT_CREATE.js create mode 100644 packages/discord.js/src/client/websocket/handlers/ENTITLEMENT_DELETE.js create mode 100644 packages/discord.js/src/client/websocket/handlers/ENTITLEMENT_UPDATE.js create mode 100644 packages/discord.js/src/managers/EntitlementManager.js create mode 100644 packages/discord.js/src/structures/Entitlement.js create mode 100644 packages/discord.js/src/structures/SKU.js create mode 100644 packages/discord.js/src/util/SKUFlagsBitField.js create mode 100644 packages/discord.js/test/monetization.js diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index dec7f5e8d2ca..7054d8625d94 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -5,6 +5,7 @@ import { ChannelsAPI } from './channel.js'; import { GuildsAPI } from './guild.js'; import { InteractionsAPI } from './interactions.js'; import { InvitesAPI } from './invite.js'; +import { MonetizationAPI } from './monetization.js'; import { OAuth2API } from './oauth2.js'; import { RoleConnectionsAPI } from './roleConnections.js'; import { StageInstancesAPI } from './stageInstances.js'; @@ -20,6 +21,7 @@ export * from './channel.js'; export * from './guild.js'; export * from './interactions.js'; export * from './invite.js'; +export * from './monetization.js'; export * from './oauth2.js'; export * from './roleConnections.js'; export * from './stageInstances.js'; @@ -42,6 +44,8 @@ export class API { public readonly invites: InvitesAPI; + public readonly monetization: MonetizationAPI; + public readonly oauth2: OAuth2API; public readonly roleConnections: RoleConnectionsAPI; @@ -64,6 +68,7 @@ export class API { this.channels = new ChannelsAPI(rest); this.guilds = new GuildsAPI(rest); this.invites = new InvitesAPI(rest); + this.monetization = new MonetizationAPI(rest); this.roleConnections = new RoleConnectionsAPI(rest); this.oauth2 = new OAuth2API(rest); this.stageInstances = new StageInstancesAPI(rest); diff --git a/packages/core/src/api/interactions.ts b/packages/core/src/api/interactions.ts index 01409a637c36..3bf7fcb3054b 100644 --- a/packages/core/src/api/interactions.ts +++ b/packages/core/src/api/interactions.ts @@ -1,14 +1,16 @@ /* eslint-disable jsdoc/check-param-names */ import type { RawFile, RequestData, REST } from '@discordjs/rest'; -import { InteractionResponseType, Routes } from 'discord-api-types/v10'; -import type { - APICommandAutocompleteInteractionResponseCallbackData, - APIInteractionResponseCallbackData, - APIModalInteractionResponseCallbackData, - RESTGetAPIWebhookWithTokenMessageResult, - Snowflake, - APIInteractionResponseDeferredChannelMessageWithSource, +import { + InteractionResponseType, + Routes, + type APICommandAutocompleteInteractionResponseCallbackData, + type APIInteractionResponseCallbackData, + type APIInteractionResponseDeferredChannelMessageWithSource, + type APIModalInteractionResponseCallbackData, + type APIPremiumRequiredInteractionResponse, + type RESTGetAPIWebhookWithTokenMessageResult, + type Snowflake, } from 'discord-api-types/v10'; import type { WebhooksAPI } from './webhook.js'; @@ -248,4 +250,26 @@ export class InteractionsAPI { signal, }); } + + /** + * Sends a premium required response to an interaction + * + * @see {@link https://discord.com/developers/docs/interactions/receiving-and-responding#create-interaction-response} + * @param interactionId - The id of the interaction + * @param interactionToken - The token of the interaction + * @param options - The options for sending the premium required response + */ + public async sendPremiumRequired( + interactionId: Snowflake, + interactionToken: string, + { signal }: Pick = {}, + ) { + await this.rest.post(Routes.interactionCallback(interactionId, interactionToken), { + auth: false, + body: { + type: InteractionResponseType.PremiumRequired, + } satisfies APIPremiumRequiredInteractionResponse, + signal, + }); + } } diff --git a/packages/core/src/api/monetization.ts b/packages/core/src/api/monetization.ts new file mode 100644 index 000000000000..12e7f5c66b83 --- /dev/null +++ b/packages/core/src/api/monetization.ts @@ -0,0 +1,80 @@ +/* eslint-disable jsdoc/check-param-names */ + +import { makeURLSearchParams, type RequestData, type REST } from '@discordjs/rest'; +import { + Routes, + type RESTGetAPIEntitlementsQuery, + type RESTGetAPIEntitlementsResult, + type RESTGetAPISKUsResult, + type RESTPostAPIEntitlementBody, + type RESTPostAPIEntitlementResult, + type Snowflake, +} from 'discord-api-types/v10'; + +export class MonetizationAPI { + public constructor(private readonly rest: REST) {} + + /** + * Fetches the SKUs for an application. + * + * @see {@link https://discord.com/developers/docs/monetization/skus#list-skus} + * @param options - The options for fetching the SKUs. + */ + public async getSKUs(applicationId: Snowflake, { signal }: Pick = {}) { + return this.rest.get(Routes.skus(applicationId), { signal }) as Promise; + } + + /** + * Fetches the entitlements for an application. + * + * @see {@link https://discord.com/developers/docs/monetization/entitlements#list-entitlements} + * @param applicationId - The application id to fetch entitlements for + * @param query - The query options for fetching entitlements + * @param options - The options for fetching entitlements + */ + public async getEntitlements( + applicationId: Snowflake, + query: RESTGetAPIEntitlementsQuery, + { signal }: Pick = {}, + ) { + return this.rest.get(Routes.entitlements(applicationId), { + signal, + query: makeURLSearchParams(query), + }) as Promise; + } + + /** + * Creates a test entitlement for an application's SKU. + * + * @see {@link https://discord.com/developers/docs/monetization/entitlements#create-test-entitlement} + * @param applicationId - The application id to create the entitlement for + * @param body - The data for creating the entitlement + * @param options - The options for creating the entitlement + */ + public async createTestEntitlement( + applicationId: Snowflake, + body: RESTPostAPIEntitlementBody, + { signal }: Pick = {}, + ) { + return this.rest.post(Routes.entitlements(applicationId), { + body, + signal, + }) as Promise; + } + + /** + * Deletes a test entitlement for an application's SKU. + * + * @see {@link https://discord.com/developers/docs/monetization/entitlements#delete-test-entitlement} + * @param applicationId - The application id to delete the entitlement for + * @param entitlementId - The entitlement id to delete + * @param options - The options for deleting the entitlement + */ + public async deleteTestEntitlement( + applicationId: Snowflake, + entitlementId: Snowflake, + { signal }: Pick = {}, + ) { + await this.rest.delete(Routes.entitlement(applicationId, entitlementId), { signal }); + } +} diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index c875058c842a..b6e996968533 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -16,6 +16,9 @@ import { type GatewayChannelDeleteDispatchData, type GatewayChannelPinsUpdateDispatchData, type GatewayChannelUpdateDispatchData, + type GatewayEntitlementCreateDispatchData, + type GatewayEntitlementDeleteDispatchData, + type GatewayEntitlementUpdateDispatchData, type GatewayGuildAuditLogEntryCreateDispatchData, type GatewayGuildBanAddDispatchData, type GatewayGuildBanRemoveDispatchData, @@ -103,6 +106,9 @@ export interface MappedEvents { [GatewayDispatchEvents.ChannelDelete]: [WithIntrinsicProps]; [GatewayDispatchEvents.ChannelPinsUpdate]: [WithIntrinsicProps]; [GatewayDispatchEvents.ChannelUpdate]: [WithIntrinsicProps]; + [GatewayDispatchEvents.EntitlementCreate]: [WithIntrinsicProps]; + [GatewayDispatchEvents.EntitlementDelete]: [WithIntrinsicProps]; + [GatewayDispatchEvents.EntitlementUpdate]: [WithIntrinsicProps]; [GatewayDispatchEvents.GuildAuditLogEntryCreate]: [WithIntrinsicProps]; [GatewayDispatchEvents.GuildBanAdd]: [WithIntrinsicProps]; [GatewayDispatchEvents.GuildBanRemove]: [WithIntrinsicProps]; diff --git a/packages/discord.js/src/client/actions/ActionsManager.js b/packages/discord.js/src/client/actions/ActionsManager.js index 301a6a966b46..7b903f907ba6 100644 --- a/packages/discord.js/src/client/actions/ActionsManager.js +++ b/packages/discord.js/src/client/actions/ActionsManager.js @@ -19,6 +19,9 @@ class ActionsManager { this.register(require('./ChannelCreate')); this.register(require('./ChannelDelete')); this.register(require('./ChannelUpdate')); + this.register(require('./EntitlementCreate')); + this.register(require('./EntitlementDelete')); + this.register(require('./EntitlementUpdate')); this.register(require('./GuildAuditLogEntryCreate')); this.register(require('./GuildBanAdd')); this.register(require('./GuildBanRemove')); diff --git a/packages/discord.js/src/client/actions/EntitlementCreate.js b/packages/discord.js/src/client/actions/EntitlementCreate.js new file mode 100644 index 000000000000..63082af25bc4 --- /dev/null +++ b/packages/discord.js/src/client/actions/EntitlementCreate.js @@ -0,0 +1,23 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class EntitlementCreateAction extends Action { + handle(data) { + const client = this.client; + + const entitlement = client.application.entitlements._add(data); + + /** + * Emitted whenever an entitlement is created. + * @event Client#entitlementCreate + * @param {Entitlement} entitlement The entitlement that was created + */ + client.emit(Events.EntitlementCreate, entitlement); + + return {}; + } +} + +module.exports = EntitlementCreateAction; diff --git a/packages/discord.js/src/client/actions/EntitlementDelete.js b/packages/discord.js/src/client/actions/EntitlementDelete.js new file mode 100644 index 000000000000..7a397548fc0a --- /dev/null +++ b/packages/discord.js/src/client/actions/EntitlementDelete.js @@ -0,0 +1,27 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class EntitlementDeleteAction extends Action { + handle(data) { + const client = this.client; + + const entitlement = client.application.entitlements._add(data, false); + + client.application.entitlements.cache.delete(entitlement.id); + + /** + * Emitted whenever an entitlement is deleted. + * Entitlements are not deleted when they expire. + * This is only triggered when Discord issues a refund or deletes the entitlement manually. + * @event Client#entitlementDelete + * @param {Entitlement} entitlement The entitlement that was deleted + */ + client.emit(Events.EntitlementDelete, entitlement); + + return {}; + } +} + +module.exports = EntitlementDeleteAction; diff --git a/packages/discord.js/src/client/actions/EntitlementUpdate.js b/packages/discord.js/src/client/actions/EntitlementUpdate.js new file mode 100644 index 000000000000..10a61dbc05f7 --- /dev/null +++ b/packages/discord.js/src/client/actions/EntitlementUpdate.js @@ -0,0 +1,25 @@ +'use strict'; + +const Action = require('./Action'); +const Events = require('../../util/Events'); + +class EntitlementUpdateAction extends Action { + handle(data) { + const client = this.client; + + const oldEntitlement = client.application.entitlements.cache.get(data.id)?._clone() ?? null; + const newEntitlement = client.application.entitlements._add(data); + + /** + * Emitted whenever an entitlement is updated - i.e. when a user's subscription renews. + * @event Client#entitlementUpdate + * @param {?Entitlement} oldEntitlement The entitlement before the update + * @param {Entitlement} newEntitlement The entitlement after the update + */ + client.emit(Events.EntitlementUpdate, oldEntitlement, newEntitlement); + + return {}; + } +} + +module.exports = EntitlementUpdateAction; diff --git a/packages/discord.js/src/client/websocket/handlers/ENTITLEMENT_CREATE.js b/packages/discord.js/src/client/websocket/handlers/ENTITLEMENT_CREATE.js new file mode 100644 index 000000000000..08e37b81955b --- /dev/null +++ b/packages/discord.js/src/client/websocket/handlers/ENTITLEMENT_CREATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.EntitlementCreate.handle(packet.d); +}; diff --git a/packages/discord.js/src/client/websocket/handlers/ENTITLEMENT_DELETE.js b/packages/discord.js/src/client/websocket/handlers/ENTITLEMENT_DELETE.js new file mode 100644 index 000000000000..d731d1bb57df --- /dev/null +++ b/packages/discord.js/src/client/websocket/handlers/ENTITLEMENT_DELETE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.EntitlementDelete.handle(packet.d); +}; diff --git a/packages/discord.js/src/client/websocket/handlers/ENTITLEMENT_UPDATE.js b/packages/discord.js/src/client/websocket/handlers/ENTITLEMENT_UPDATE.js new file mode 100644 index 000000000000..51534bbe4076 --- /dev/null +++ b/packages/discord.js/src/client/websocket/handlers/ENTITLEMENT_UPDATE.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = (client, packet) => { + client.actions.EntitlementUpdate.handle(packet.d); +}; diff --git a/packages/discord.js/src/client/websocket/handlers/index.js b/packages/discord.js/src/client/websocket/handlers/index.js index f175dbe22d9b..0ed4b4065a0c 100644 --- a/packages/discord.js/src/client/websocket/handlers/index.js +++ b/packages/discord.js/src/client/websocket/handlers/index.js @@ -10,6 +10,9 @@ const handlers = Object.fromEntries([ ['CHANNEL_DELETE', require('./CHANNEL_DELETE')], ['CHANNEL_PINS_UPDATE', require('./CHANNEL_PINS_UPDATE')], ['CHANNEL_UPDATE', require('./CHANNEL_UPDATE')], + ['ENTITLEMENT_CREATE', require('./ENTITLEMENT_CREATE')], + ['ENTITLEMENT_DELETE', require('./ENTITLEMENT_DELETE')], + ['ENTITLEMENT_UPDATE', require('./ENTITLEMENT_UPDATE')], ['GUILD_AUDIT_LOG_ENTRY_CREATE', require('./GUILD_AUDIT_LOG_ENTRY_CREATE')], ['GUILD_BAN_ADD', require('./GUILD_BAN_ADD')], ['GUILD_BAN_REMOVE', require('./GUILD_BAN_REMOVE')], diff --git a/packages/discord.js/src/errors/ErrorCodes.js b/packages/discord.js/src/errors/ErrorCodes.js index 88f28a471ba6..a161dd285c6a 100644 --- a/packages/discord.js/src/errors/ErrorCodes.js +++ b/packages/discord.js/src/errors/ErrorCodes.js @@ -173,6 +173,8 @@ * @property {'GuildForumMessageRequired'} GuildForumMessageRequired * @property {'SweepFilterReturn'} SweepFilterReturn + + * @property {'EntitlementCreateInvalidOwner'} EntitlementCreateInvalidOwner */ const keys = [ @@ -323,6 +325,8 @@ const keys = [ 'SweepFilterReturn', 'GuildForumMessageRequired', + + 'EntitlementCreateInvalidOwner', ]; // JSDoc for IntelliSense purposes diff --git a/packages/discord.js/src/errors/Messages.js b/packages/discord.js/src/errors/Messages.js index 550219f59ed7..5c6e3ac71fa9 100644 --- a/packages/discord.js/src/errors/Messages.js +++ b/packages/discord.js/src/errors/Messages.js @@ -165,6 +165,9 @@ const Messages = { [DjsErrorCodes.SweepFilterReturn]: 'The return value of the sweepFilter function was not false or a Function', [DjsErrorCodes.GuildForumMessageRequired]: 'You must provide a message to create a guild forum thread', + + [DjsErrorCodes.EntitlementCreateInvalidOwner]: + 'You must provide either a guild or a user to create an entitlement, but not both', }; module.exports = Messages; diff --git a/packages/discord.js/src/index.js b/packages/discord.js/src/index.js index b74f71fa4323..67212096e1f6 100644 --- a/packages/discord.js/src/index.js +++ b/packages/discord.js/src/index.js @@ -38,6 +38,7 @@ exports.Partials = require('./util/Partials'); exports.PermissionsBitField = require('./util/PermissionsBitField'); exports.RoleFlagsBitField = require('./util/RoleFlagsBitField'); exports.ShardEvents = require('./util/ShardEvents'); +exports.SKUFlagsBitField = require('./util/SKUFlagsBitField').SKUFlagsBitField; exports.Status = require('./util/Status'); exports.SnowflakeUtil = require('@sapphire/snowflake').DiscordSnowflake; exports.Sweepers = require('./util/Sweepers'); @@ -58,6 +59,7 @@ exports.ChannelManager = require('./managers/ChannelManager'); exports.ClientVoiceManager = require('./client/voice/ClientVoiceManager'); exports.DataManager = require('./managers/DataManager'); exports.DMMessageManager = require('./managers/DMMessageManager'); +exports.EntitlementManager = require('./managers/EntitlementManager').EntitlementManager; exports.GuildApplicationCommandManager = require('./managers/GuildApplicationCommandManager'); exports.GuildBanManager = require('./managers/GuildBanManager'); exports.GuildChannelManager = require('./managers/GuildChannelManager'); @@ -121,6 +123,7 @@ exports.DMChannel = require('./structures/DMChannel'); exports.Embed = require('./structures/Embed'); exports.EmbedBuilder = require('./structures/EmbedBuilder'); exports.Emoji = require('./structures/Emoji').Emoji; +exports.Entitlement = require('./structures/Entitlement').Entitlement; exports.ForumChannel = require('./structures/ForumChannel'); exports.Guild = require('./structures/Guild').Guild; exports.GuildAuditLogs = require('./structures/GuildAuditLogs'); @@ -188,6 +191,7 @@ exports.RoleSelectMenuInteraction = require('./structures/RoleSelectMenuInteract exports.StringSelectMenuInteraction = require('./structures/StringSelectMenuInteraction'); exports.UserSelectMenuInteraction = require('./structures/UserSelectMenuInteraction'); exports.SelectMenuOptionBuilder = require('./structures/SelectMenuOptionBuilder'); +exports.SKU = require('./structures/SKU').SKU; exports.StringSelectMenuOptionBuilder = require('./structures/StringSelectMenuOptionBuilder'); exports.StageChannel = require('./structures/StageChannel'); exports.StageInstance = require('./structures/StageInstance').StageInstance; diff --git a/packages/discord.js/src/managers/EntitlementManager.js b/packages/discord.js/src/managers/EntitlementManager.js new file mode 100644 index 000000000000..d4c536a87fae --- /dev/null +++ b/packages/discord.js/src/managers/EntitlementManager.js @@ -0,0 +1,129 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { makeURLSearchParams } = require('@discordjs/rest'); +const { Routes, EntitlementOwnerType } = require('discord-api-types/v10'); +const CachedManager = require('./CachedManager'); +const { ErrorCodes, DiscordjsTypeError } = require('../errors/index'); +const { Entitlement } = require('../structures/Entitlement'); +const { resolveSKUId } = require('../util/Util'); + +/** + * Manages API methods for entitlements and stores their cache. + * @extends {CachedManager} + */ +class EntitlementManager extends CachedManager { + constructor(client, iterable) { + super(client, Entitlement, iterable); + } + + /** + * The cache of this manager + * @type {Collection} + * @name EntitlementManager#cache + */ + + /** + * Data that resolves to give an Entitlement object. This can be: + * * An Entitlement object + * * A Snowflake + * @typedef {Entitlement|Snowflake} EntitlementResolvable + */ + + /** + * Data that resolves to give a SKU object. This can be: + * * A SKU object + * * A Snowflake + * @typedef {SKU|Snowflake} SKUResolvable + */ + + /** + * Options used to fetch entitlements + * @typedef {Object} FetchEntitlementsOptions + * @property {number} [limit] The maximum number of entitlements to fetch + * @property {GuildResolvable} [guild] The guild to fetch entitlements for + * @property {UserResolvable} [user] The user to fetch entitlements for + * @property {SKUResolvable[]} [skus] The skus to fetch entitlements for + * @property {boolean} [excludeEnded] Whether to exclude ended entitlements + * @property {boolean} [cache=true] Whether to cache the fetched entitlements + * @property {Snowflake} [before] Consider only entitlements before this entitlement id + * @property {Snowflake} [after] Consider only entitlements after this entitlement id + * If both `before` and `after` are provided, only `before` is respected + */ + + /** + * Fetches entitlements for this application + * @param {FetchEntitlementsOptions} [options={}] Options for fetching the entitlements + * @returns {Promise>} + */ + async fetch({ limit, guild, user, skus, excludeEnded, cache = true, before, after } = {}) { + const query = makeURLSearchParams({ + limit, + guild_id: guild && this.client.guilds.resolveId(guild), + user_id: user && this.client.users.resolveId(user), + sku_ids: skus?.map(sku => resolveSKUId(sku)).join(','), + exclude_ended: excludeEnded, + before, + after, + }); + + const entitlements = await this.client.rest.get(Routes.entitlements(this.client.application.id), { query }); + return entitlements.reduce( + (coll, entitlement) => coll.set(entitlement.id, this._add(entitlement, cache)), + new Collection(), + ); + } + + /** + * Options used to create a test entitlement + * Either `guild` or `user` must be provided, but not both + * @typedef {Object} EntitlementCreateOptions + * @property {SKUResolvable} sku The id of the sku to create the entitlement for + * @property {GuildResolvable} [guild] The guild to create the entitlement for + * @property {UserResolvable} [user] The user to create the entitlement for + */ + + /** + * Creates a test entitlement + * @param {EntitlementCreateOptions} options Options for creating the test entitlement + * @returns {Promise} + */ + async createTest({ sku, guild, user }) { + const skuId = resolveSKUId(sku); + if (!skuId) throw new DiscordjsTypeError(ErrorCodes.InvalidType, 'sku', 'SKUResolvable'); + + if ((guild && user) || (!guild && !user)) { + throw new DiscordjsTypeError(ErrorCodes.EntitlementCreateInvalidOwner); + } + + const resolved = guild ? this.client.guilds.resolveId(guild) : this.client.users.resolveId(user); + if (!resolved) { + const name = guild ? 'guild' : 'user'; + const type = guild ? 'GuildResolvable' : 'UserResolvable'; + throw new DiscordjsTypeError(ErrorCodes.InvalidType, name, type); + } + + const entitlement = await this.client.rest.post(Routes.entitlements(this.client.application.id), { + body: { + sku_id: skuId, + owner_id: resolved, + owner_type: guild ? EntitlementOwnerType.Guild : EntitlementOwnerType.User, + }, + }); + return new Entitlement(this.client, entitlement); + } + + /** + * Deletes a test entitlement + * @param {EntitlementResolvable} entitlement The entitlement to delete + * @returns {Promise} + */ + async deleteTest(entitlement) { + const resolved = this.resolveId(entitlement); + if (!resolved) throw new DiscordjsTypeError(ErrorCodes.InvalidType, 'entitlement', 'EntitlementResolvable'); + + await this.client.rest.delete(Routes.entitlement(this.client.application.id, resolved)); + } +} + +exports.EntitlementManager = EntitlementManager; diff --git a/packages/discord.js/src/structures/BaseInteraction.js b/packages/discord.js/src/structures/BaseInteraction.js index 967350fd0449..357a88da37bd 100644 --- a/packages/discord.js/src/structures/BaseInteraction.js +++ b/packages/discord.js/src/structures/BaseInteraction.js @@ -1,6 +1,7 @@ 'use strict'; const { deprecate } = require('node:util'); +const { Collection } = require('@discordjs/collection'); const { DiscordSnowflake } = require('@sapphire/snowflake'); const { InteractionType, ApplicationCommandType, ComponentType } = require('discord-api-types/v10'); const Base = require('./Base'); @@ -133,6 +134,15 @@ class BaseInteraction extends Base { * @type {?Locale} */ this.guildLocale = data.guild_locale ?? null; + + /** + * The entitlements for the invoking user, representing access to premium SKUs + * @type {Collection} + */ + this.entitlements = data.entitlements.reduce( + (coll, entitlement) => coll.set(entitlement.id, this.client.application.entitlements._add(entitlement)), + new Collection(), + ); } /** diff --git a/packages/discord.js/src/structures/ClientApplication.js b/packages/discord.js/src/structures/ClientApplication.js index 0c9588bad2f3..84a1c0ac716c 100644 --- a/packages/discord.js/src/structures/ClientApplication.js +++ b/packages/discord.js/src/structures/ClientApplication.js @@ -1,10 +1,13 @@ 'use strict'; +const { Collection } = require('@discordjs/collection'); const { Routes } = require('discord-api-types/v10'); const { ApplicationRoleConnectionMetadata } = require('./ApplicationRoleConnectionMetadata'); +const { SKU } = require('./SKU'); const Team = require('./Team'); const Application = require('./interfaces/Application'); const ApplicationCommandManager = require('../managers/ApplicationCommandManager'); +const { EntitlementManager } = require('../managers/EntitlementManager'); const ApplicationFlagsBitField = require('../util/ApplicationFlagsBitField'); const { resolveImage } = require('../util/DataResolver'); const PermissionsBitField = require('../util/PermissionsBitField'); @@ -28,6 +31,12 @@ class ClientApplication extends Application { * @type {ApplicationCommandManager} */ this.commands = new ApplicationCommandManager(this.client); + + /** + * The entitlement manager for this application + * @type {EntitlementManager} + */ + this.entitlements = new EntitlementManager(this.client); } _patch(data) { @@ -287,6 +296,15 @@ class ClientApplication extends Application { return newRecords.map(data => new ApplicationRoleConnectionMetadata(data)); } + + /** + * Gets this application's skus + * @returns {Promise>} + */ + async fetchSKUs() { + const skus = await this.client.rest.get(Routes.skus(this.id)); + return skus.reduce((coll, sku) => coll.set(sku.id, new SKU(this.client, sku)), new Collection()); + } } module.exports = ClientApplication; diff --git a/packages/discord.js/src/structures/CommandInteraction.js b/packages/discord.js/src/structures/CommandInteraction.js index 88086f9605b2..0d435deeb446 100644 --- a/packages/discord.js/src/structures/CommandInteraction.js +++ b/packages/discord.js/src/structures/CommandInteraction.js @@ -153,6 +153,7 @@ class CommandInteraction extends BaseInteraction { deleteReply() {} followUp() {} showModal() {} + sendPremiumRequired() {} awaitModalSubmit() {} } diff --git a/packages/discord.js/src/structures/Entitlement.js b/packages/discord.js/src/structures/Entitlement.js new file mode 100644 index 000000000000..9560a97709d9 --- /dev/null +++ b/packages/discord.js/src/structures/Entitlement.js @@ -0,0 +1,164 @@ +'use strict'; + +const Base = require('./Base'); + +/** + * Represents an Entitlement + * @extends {Base} + */ +class Entitlement extends Base { + constructor(client, data) { + super(client); + + /** + * The id of the entitlement + * @type {Snowflake} + */ + this.id = data.id; + + this._patch(data); + } + + _patch(data) { + if ('sku_id' in data) { + /** + * The id of the associated SKU + * @type {Snowflake} + */ + this.skuId = data.sku_id; + } + + if ('user_id' in data) { + /** + * The id of the user that is granted access to this entitlement's SKU + * @type {Snowflake} + */ + this.userId = data.user_id; + } + + if ('guild_id' in data) { + /** + * The id of the guild that is granted access to this entitlement's SKU + * @type {?Snowflake} + */ + this.guildId = data.guild_id; + } else { + this.guildId ??= null; + } + + if ('application_id' in data) { + /** + * The id of the parent application + * @type {Snowflake} + */ + this.applicationId = data.application_id; + } + + if ('type' in data) { + /** + * The type of this entitlement + * @type {EntitlementType} + */ + this.type = data.type; + } + + if ('deleted' in data) { + /** + * Whether this entitlement was deleted + * @type {boolean} + */ + this.deleted = data.deleted; + } + + if ('starts_at' in data) { + /** + * The timestamp at which this entitlement is valid + * This is only `null` for test entitlements + * @type {?number} + */ + this.startsTimestamp = Date.parse(data.starts_at); + } else { + this.startsTimestamp ??= null; + } + + if ('ends_at' in data) { + /** + * The timestamp at which this entitlement is no longer valid + * This is only `null` for test entitlements + * @type {?number} + */ + this.endsTimestamp = Date.parse(data.ends_at); + } else { + this.endsTimestamp ??= null; + } + } + + /** + * The guild that is granted access to this entitlement's SKU + * @type {?Guild} + */ + get guild() { + if (!this.guildId) return null; + return this.client.guilds.cache.get(this.guildId) ?? null; + } + + /** + * The start date at which this entitlement is valid + * This is only `null` for test entitlements + * @type {?Date} + */ + get startsAt() { + return this.startsTimestamp && new Date(this.startsTimestamp); + } + + /** + * The end date at which this entitlement is no longer valid + * This is only `null` for test entitlements + * @type {?Date} + */ + get endsAt() { + return this.endsTimestamp && new Date(this.endsTimestamp); + } + + /** + * Indicates whether this entitlement is active + * @returns {boolean} + */ + isActive() { + return !this.deleted && (!this.endsTimestamp || this.endsTimestamp > Date.now()); + } + + /** + * Indicates whether this entitlement is a test entitlement + * @returns {boolean} + */ + isTest() { + return this.startsTimestamp === null; + } + + /** + * Indicates whether this entitlement is a user subscription + * @returns {boolean} + */ + isUserSubscription() { + return this.guildId === null; + } + + /** + * Indicates whether this entitlement is a guild subscription + * @returns {boolean} + */ + isGuildSubscription() { + return this.guildId !== null; + } + + /** + * Fetches the user that is granted access to this entitlement's SKU + * @returns {Promise} + */ + fetchUser() { + return this.client.users.fetch(this.userId); + } +} + +exports.Entitlement = Entitlement; diff --git a/packages/discord.js/src/structures/MessageComponentInteraction.js b/packages/discord.js/src/structures/MessageComponentInteraction.js index 47b31e04c12c..2e6df11e5611 100644 --- a/packages/discord.js/src/structures/MessageComponentInteraction.js +++ b/packages/discord.js/src/structures/MessageComponentInteraction.js @@ -99,6 +99,7 @@ class MessageComponentInteraction extends BaseInteraction { deferUpdate() {} update() {} showModal() {} + sendPremiumRequired() {} awaitModalSubmit() {} } diff --git a/packages/discord.js/src/structures/ModalSubmitInteraction.js b/packages/discord.js/src/structures/ModalSubmitInteraction.js index 559807bfa078..ba94190436da 100644 --- a/packages/discord.js/src/structures/ModalSubmitInteraction.js +++ b/packages/discord.js/src/structures/ModalSubmitInteraction.js @@ -118,6 +118,7 @@ class ModalSubmitInteraction extends BaseInteraction { followUp() {} deferUpdate() {} update() {} + sendPremiumRequired() {} } InteractionResponses.applyToClass(ModalSubmitInteraction, 'showModal'); diff --git a/packages/discord.js/src/structures/SKU.js b/packages/discord.js/src/structures/SKU.js new file mode 100644 index 000000000000..3ce4eeba3704 --- /dev/null +++ b/packages/discord.js/src/structures/SKU.js @@ -0,0 +1,52 @@ +'use strict'; + +const Base = require('./Base'); +const { SKUFlagsBitField } = require('../util/SKUFlagsBitField'); + +/** + * Represents a premium application SKU. + * @extends {Base} + */ +class SKU extends Base { + constructor(client, data) { + super(client); + + /** + * The id of the SKU + * @type {Snowflake} + */ + this.id = data.id; + + /** + * The type of the SKU + * @type {SKUType} + */ + this.type = data.type; + + /** + * The id of the parent application + * @type {Snowflake} + */ + this.applicationId = data.application_id; + + /** + * The customer-facing name of the premium offering + * @type {string} + */ + this.name = data.name; + + /** + * The system-generated URL slug based on this SKU's name + * @type {string} + */ + this.slug = data.slug; + + /** + * Flags that describe the SKU + * @type {Readonly} + */ + this.flags = new SKUFlagsBitField(data.flags).freeze(); + } +} + +exports.SKU = SKU; diff --git a/packages/discord.js/src/structures/interfaces/InteractionResponses.js b/packages/discord.js/src/structures/interfaces/InteractionResponses.js index 2704f42764c6..10ebd40715d6 100644 --- a/packages/discord.js/src/structures/interfaces/InteractionResponses.js +++ b/packages/discord.js/src/structures/interfaces/InteractionResponses.js @@ -262,6 +262,22 @@ class InteractionResponses { this.replied = true; } + /** + * Responds to the interaction with an upgrade button. + * Only available for applications with monetization enabled. + * @returns {Promise} + */ + async sendPremiumRequired() { + if (this.deferred || this.replied) throw new DiscordjsError(ErrorCodes.InteractionAlreadyReplied); + await this.client.rest.post(Routes.interactionCallback(this.id, this.token), { + body: { + type: InteractionResponseType.PremiumRequired, + }, + auth: false, + }); + this.replied = true; + } + /** * An object containing the same properties as {@link CollectorOptions}, but a few less: * @typedef {Object} AwaitModalSubmitOptions @@ -305,6 +321,7 @@ class InteractionResponses { 'deferUpdate', 'update', 'showModal', + 'sendPremiumRequired', 'awaitModalSubmit', ]; diff --git a/packages/discord.js/src/util/APITypes.js b/packages/discord.js/src/util/APITypes.js index a1b4fd827312..344092010f72 100644 --- a/packages/discord.js/src/util/APITypes.js +++ b/packages/discord.js/src/util/APITypes.js @@ -280,6 +280,11 @@ * @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/ComponentType} */ +/** + * @external EntitlementType + * @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/EntitlementType} + */ + /** * @external ForumLayoutType * @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/ForumLayoutType} @@ -460,6 +465,16 @@ * @see {@link https://discord-api-types.dev/api/discord-api-types-rest/common/enum/RESTJSONErrorCodes} */ +/** + * @external SKUFlags + * @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/SKUFlags} + */ + +/** + * @external SKUType + * @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/SKUType} + */ + /** * @external SortOrderType * @see {@link https://discord-api-types.dev/api/discord-api-types-v10/enum/SortOrderType} diff --git a/packages/discord.js/src/util/Constants.js b/packages/discord.js/src/util/Constants.js index c2d3da95f403..6ff5f57b6141 100644 --- a/packages/discord.js/src/util/Constants.js +++ b/packages/discord.js/src/util/Constants.js @@ -14,6 +14,7 @@ exports.MaxBulkDeletableMessageAge = 1_209_600_000; * * `applicationCommands` - both global and guild commands * * `bans` * * `emojis` + * * `entitlements` * * `invites` - accepts the `lifetime` property, using it will sweep based on expires timestamp * * `guildMembers` * * `messages` - accepts the `lifetime` property, using it will sweep based on edited or created timestamp @@ -32,6 +33,7 @@ exports.SweeperKeys = [ 'applicationCommands', 'bans', 'emojis', + 'entitlements', 'invites', 'guildMembers', 'messages', diff --git a/packages/discord.js/src/util/Events.js b/packages/discord.js/src/util/Events.js index 1a83ec81e3fa..416270add89f 100644 --- a/packages/discord.js/src/util/Events.js +++ b/packages/discord.js/src/util/Events.js @@ -14,6 +14,9 @@ * @property {string} ChannelUpdate channelUpdate * @property {string} ClientReady ready * @property {string} Debug debug + * @property {string} EntitlementCreate entitlementCreate + * @property {string} EntitlementUpdate entitlementUpdate + * @property {string} EntitlementDelete entitlementDelete * @property {string} Error error * @property {string} GuildAuditLogEntryCreate guildAuditLogEntryCreate * @property {string} GuildAvailable guildAvailable @@ -96,6 +99,9 @@ module.exports = { ChannelUpdate: 'channelUpdate', ClientReady: 'ready', Debug: 'debug', + EntitlementCreate: 'entitlementCreate', + EntitlementUpdate: 'entitlementUpdate', + EntitlementDelete: 'entitlementDelete', Error: 'error', GuildAuditLogEntryCreate: 'guildAuditLogEntryCreate', GuildAvailable: 'guildAvailable', diff --git a/packages/discord.js/src/util/SKUFlagsBitField.js b/packages/discord.js/src/util/SKUFlagsBitField.js new file mode 100644 index 000000000000..d70813722bdc --- /dev/null +++ b/packages/discord.js/src/util/SKUFlagsBitField.js @@ -0,0 +1,26 @@ +'use strict'; + +const { SKUFlags } = require('discord-api-types/v10'); +const BitField = require('./BitField'); + +/** + * Data structure that makes it easy to interact with an {@link SKU#flags} bitfield. + * @extends {BitField} + */ +class SKUFlagsBitField extends BitField { + /** + * Numeric SKU flags. + * @type {SKUFlags} + * @memberof SKUFlagsBitField + */ + static Flags = SKUFlags; +} + +/** + * @name SKUFlagsBitField + * @kind constructor + * @memberof SKUFlagsBitField + * @param {BitFieldResolvable} [bits=0] Bit(s) to read from + */ + +exports.SKUFlagsBitField = SKUFlagsBitField; diff --git a/packages/discord.js/src/util/Sweepers.js b/packages/discord.js/src/util/Sweepers.js index 3402f2fe9aa6..3ff81814d57e 100644 --- a/packages/discord.js/src/util/Sweepers.js +++ b/packages/discord.js/src/util/Sweepers.js @@ -106,6 +106,23 @@ class Sweepers { return this._sweepGuildDirectProp('emojis', filter).items; } + /** + * Sweeps all client application entitlements and removes the ones which are indicated by the filter. + * @param {Function} filter The function used to determine which entitlements will be removed from the caches. + * @returns {number} Amount of entitlements that were removed from the caches + */ + sweepEntitlements(filter) { + if (typeof filter !== 'function') { + throw new DiscordjsTypeError(ErrorCodes.InvalidType, 'filter', 'function'); + } + + const entitlements = this.client.application.entitlements.cache.sweep(filter); + + this.client.emit(Events.CacheSweep, `Swept ${entitlements} entitlements.`); + + return entitlements; + } + /** * Sweeps all guild invites and removes the ones which are indicated by the filter. * @param {Function} filter The function used to determine which invites will be removed from the caches. diff --git a/packages/discord.js/src/util/Util.js b/packages/discord.js/src/util/Util.js index 277a5baca8ac..b238a22c8834 100644 --- a/packages/discord.js/src/util/Util.js +++ b/packages/discord.js/src/util/Util.js @@ -479,6 +479,17 @@ function transformResolved( return result; } +/** + * Resolves a SKU id from a SKU resolvable. + * @param {SKUResolvable} resolvable The SKU resolvable to resolve + * @returns {?Snowflake} The resolved SKU id, or `null` if the resolvable was invalid + */ +function resolveSKUId(resolvable) { + if (typeof resolvable === 'string') return resolvable; + if (resolvable instanceof SKU) return resolvable.id; + return null; +} + module.exports = { flatten, fetchRecommendedShardCount, @@ -497,8 +508,10 @@ module.exports = { cleanCodeBlockContent, parseWebhookURL, transformResolved, + resolveSKUId, }; // Fixes Circular const Attachment = require('../structures/Attachment'); const GuildChannel = require('../structures/GuildChannel'); +const { SKU } = require('../structures/SKU.js'); diff --git a/packages/discord.js/test/monetization.js b/packages/discord.js/test/monetization.js new file mode 100644 index 000000000000..375c7a48f6e7 --- /dev/null +++ b/packages/discord.js/test/monetization.js @@ -0,0 +1,59 @@ +'use strict'; + +const { token, owner } = require('./auth.js'); +const { Client, Events, codeBlock, GatewayIntentBits } = require('../src'); + +const client = new Client({ intents: GatewayIntentBits.Guilds | GatewayIntentBits.GuildMessages }); + +client.on('raw', console.log); + +client.on(Events.ClientReady, async () => { + const commands = await client.application.commands.fetch(); + if (!commands.size) { + await client.application.commands.set([ + { + name: 'test', + description: 'yeet', + }, + ]); + } + + const skus = await client.application.fetchSKUs(); + console.log('skus', skus); + + const entitlements = await client.application.entitlements.fetch(); + console.log('entitlements', entitlements); +}); + +client.on(Events.EntitlementCreate, entitlement => console.log('EntitlementCreate', entitlement)); +client.on(Events.EntitlementDelete, entitlement => console.log('EntitlementDelete', entitlement)); +client.on(Events.EntitlementUpdate, (oldEntitlement, newEntitlement) => + console.log('EntitlementUpdate', oldEntitlement, newEntitlement), +); + +client.on(Events.InteractionCreate, async interaction => { + console.log('interaction.entitlements', interaction.entitlements); + + if (interaction.commandName === 'test') { + await interaction.sendPremiumRequired(); + } +}); + +client.on(Events.MessageCreate, async message => { + const prefix = `<@${client.user.id}> `; + + if (message.author.id !== owner || !message.content.startsWith(prefix)) return; + let res; + try { + res = await eval(message.content.slice(prefix.length)); + if (typeof res !== 'string') res = require('node:util').inspect(res); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err.stack); + res = err.message; + } + + await message.channel.send(codeBlock('js', res)); +}); + +client.login(token); diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 2bb8cd4699fe..ed5a7c525f5d 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -170,6 +170,11 @@ import { TeamMemberRole, GuildWidgetStyle, GuildOnboardingMode, + APISKU, + SKUFlags, + SKUType, + APIEntitlement, + EntitlementType, } from 'discord-api-types/v10'; import { ChildProcess } from 'node:child_process'; import { EventEmitter } from 'node:events'; @@ -585,6 +590,7 @@ export abstract class CommandInteraction e | ModalComponentData | APIModalInteractionResponseCallbackData, ): Promise; + public sendPremiumRequired(): Promise; public awaitModalSubmit( options: AwaitModalSubmitOptions, ): Promise>; @@ -1033,6 +1039,7 @@ export class ClientApplication extends Application { public botRequireCodeGrant: boolean | null; public bot: User | null; public commands: ApplicationCommandManager; + public entitlements: EntitlementManager; public guildId: Snowflake | null; public get guild(): Guild | null; public cover: string | null; @@ -1049,6 +1056,7 @@ export class ClientApplication extends Application { public edit(options: ClientApplicationEditOptions): Promise; public fetch(): Promise; public fetchRoleConnectionMetadataRecords(): Promise; + public fetchSKUs(): Promise>; public editRoleConnectionMetadataRecords( records: ApplicationRoleConnectionMetadataEditOptions[], ): Promise; @@ -1305,6 +1313,32 @@ export class Emoji extends Base { public toString(): string; } +export class Entitlement extends Base { + private constructor(client: Client, data: APIEntitlement); + public id: Snowflake; + public skuId: Snowflake; + public userId: Snowflake; + public guildId: Snowflake | null; + public applicationId: Snowflake; + public type: EntitlementType; + public deleted: boolean; + public startsTimestamp: number | null; + public endsTimestamp: number | null; + public get guild(): Guild | null; + public get startsAt(): Date | null; + public get endsAt(): Date | null; + public fetchUser(): Promise; + public isActive(): boolean; + public isTest(): this is this & { + startsTimestamp: null; + endsTimestamp: null; + get startsAt(): null; + get endsAt(): null; + }; + public isUserSubscription(): this is this & { guildId: null; get guild(): null }; + public isGuildSubscription(): this is this & { guildId: Snowflake; guild: Guild }; +} + export class Guild extends AnonymousGuild { private constructor(client: Client, data: RawGuildData); private _sortedRoles(): Collection; @@ -1829,6 +1863,7 @@ export class BaseInteraction extends Base public memberPermissions: CacheTypeReducer>; public locale: Locale; public guildLocale: CacheTypeReducer; + public entitlements: Collection; public inGuild(): this is BaseInteraction<'raw' | 'cached'>; public inCachedGuild(): this is BaseInteraction<'cached'>; public inRawGuild(): this is BaseInteraction<'raw'>; @@ -2178,6 +2213,7 @@ export class MessageComponentInteraction e | ModalComponentData | APIModalInteractionResponseCallbackData, ): Promise; + public sendPremiumRequired(): Promise; public awaitModalSubmit( options: AwaitModalSubmitOptions, ): Promise>; @@ -2376,6 +2412,7 @@ export class ModalSubmitInteraction extend options: InteractionDeferUpdateOptions & { fetchReply: true }, ): Promise>>; public deferUpdate(options?: InteractionDeferUpdateOptions): Promise>>; + public sendPremiumRequired(): Promise; public inGuild(): this is ModalSubmitInteraction<'raw' | 'cached'>; public inCachedGuild(): this is ModalSubmitInteraction<'cached'>; public inRawGuild(): this is ModalSubmitInteraction<'raw'>; @@ -2875,6 +2912,23 @@ export { DeconstructedSnowflake, } from '@sapphire/snowflake'; +export class SKU extends Base { + private constructor(client: Client, data: APISKU); + public id: Snowflake; + public type: SKUType; + public applicationId: Snowflake; + public name: string; + public slug: string; + public flags: Readonly; +} + +export type SKUFlagsString = keyof typeof SKUFlags; + +export class SKUFlagsBitField extends BitField { + public static FLAGS: typeof SKUFlags; + public static resolve(bit?: BitFieldResolvable): number; +} + export class StageChannel extends BaseGuildVoiceChannel { public get stageInstance(): StageInstance | null; public topic: string | null; @@ -2974,6 +3028,9 @@ export class Sweepers { public sweepEmojis( filter: CollectionSweepFilter, ): number; + public sweepEntitlements( + filter: CollectionSweepFilter, + ): number; public sweepInvites( filter: CollectionSweepFilter, ): number; @@ -3281,6 +3338,7 @@ export function transformResolved( supportingData: SupportingInteractionResolvedData, data?: APIApplicationCommandInteractionData['resolved'], ): CommandInteractionResolvedData; +export function resolveSKUId(resolvable: SKUResolvable): Snowflake | null; export interface MappedComponentBuilderTypes { [ComponentType.Button]: ButtonBuilder; @@ -3819,6 +3877,8 @@ export enum DiscordjsErrorCodes { SweepFilterReturn = 'SweepFilterReturn', GuildForumMessageRequired = 'GuildForumMessageRequired', + + EntitlementCreateInvalidOwner = 'EntitlementCreateInvalidOwner', } /** @internal */ @@ -4002,6 +4062,37 @@ export class ChannelManager extends CachedManager; } +export type EntitlementResolvable = Snowflake | Entitlement; +export type SKUResolvable = Snowflake | SKU; + +export interface GuildEntitlementCreateOptions { + sku: SKUResolvable; + guild: GuildResolvable; +} + +export interface UserEntitlementCreateOptions { + sku: SKUResolvable; + user: UserResolvable; +} + +export interface FetchEntitlementOptions { + limit?: number; + guild?: GuildResolvable; + user?: UserResolvable; + skus?: SKUResolvable[]; + excludeEnded?: boolean; + cache?: boolean; + before?: Snowflake; + after?: Snowflake; +} + +export class EntitlementManager extends CachedManager { + private constructor(client: Client, iterable: Iterable); + public fetch(options?: FetchEntitlementOptions): Promise>; + public createTest(options: GuildEntitlementCreateOptions | UserEntitlementCreateOptions): Promise; + public deleteTest(entitlement: EntitlementResolvable): Promise; +} + export interface FetchGuildApplicationCommandFetchOptions extends Omit {} export class GuildApplicationCommandManager extends ApplicationCommandManager { @@ -4980,6 +5071,9 @@ export interface ClientEvents { emojiCreate: [emoji: GuildEmoji]; emojiDelete: [emoji: GuildEmoji]; emojiUpdate: [oldEmoji: GuildEmoji, newEmoji: GuildEmoji]; + entitlementCreate: [entitlement: Entitlement]; + entitlementDelete: [entitlement: Entitlement]; + entitlementUpdate: [oldEntitlement: Entitlement | null, newEntitlement: Entitlement]; error: [error: Error]; guildAuditLogEntryCreate: [auditLogEntry: GuildAuditLogsEntry, guild: Guild]; guildAvailable: [guild: Guild]; @@ -5192,6 +5286,9 @@ export enum Events { AutoModerationRuleDelete = 'autoModerationRuleDelete', AutoModerationRuleUpdate = 'autoModerationRuleUpdate', ClientReady = 'ready', + EntitlementCreate = 'entitlementCreate', + EntitlementDelete = 'entitlementDelete', + EntitlementUpdate = 'entitlementUpdate', GuildAuditLogEntryCreate = 'guildAuditLogEntryCreate', GuildAvailable = 'guildAvailable', GuildCreate = 'guildCreate', @@ -6475,6 +6572,7 @@ export interface SweeperDefinitions { autoModerationRules: [Snowflake, AutoModerationRule]; bans: [Snowflake, GuildBan]; emojis: [Snowflake, GuildEmoji]; + entitlements: [Snowflake, Entitlement]; invites: [string, Invite, true]; guildMembers: [Snowflake, GuildMember]; messages: [Snowflake, Message, true]; diff --git a/packages/discord.js/typings/index.test-d.ts b/packages/discord.js/typings/index.test-d.ts index 44b53da81984..b7d9d62227e3 100644 --- a/packages/discord.js/typings/index.test-d.ts +++ b/packages/discord.js/typings/index.test-d.ts @@ -188,6 +188,8 @@ import { Awaitable, Channel, DirectoryChannel, + Entitlement, + SKU, } from '.'; import { expectAssignable, expectNotAssignable, expectNotType, expectType } from 'tsd'; import type { ContextMenuCommandBuilder, SlashCommandBuilder } from '@discordjs/builders'; @@ -2426,3 +2428,55 @@ declare const emoji: Emoji; expectType(resolvePartialEmoji('12345678901234567')); expectType(resolvePartialEmoji(emoji)); } + +declare const application: ClientApplication; +declare const entitlement: Entitlement; +declare const sku: SKU; +{ + expectType>(await application.fetchSKUs()); + expectType>(await application.entitlements.fetch()); + + await application.entitlements.fetch({ + guild, + skus: ['12345678901234567', sku], + user, + excludeEnded: true, + limit: 10, + }); + + await application.entitlements.createTest({ sku: '12345678901234567', user }); + await application.entitlements.createTest({ sku, guild }); + + await application.entitlements.deleteTest(entitlement); + + expectType(entitlement.isActive()); + + if (entitlement.isUserSubscription()) { + expectType(entitlement.userId); + expectType(await entitlement.fetchUser()); + expectType(entitlement.guildId); + expectType(entitlement.guild); + + await application.entitlements.deleteTest(entitlement); + } else if (entitlement.isGuildSubscription()) { + expectType(entitlement.guildId); + expectType(entitlement.guild); + + await application.entitlements.deleteTest(entitlement); + } + + if (entitlement.isTest()) { + expectType(entitlement.startsTimestamp); + expectType(entitlement.endsTimestamp); + expectType(entitlement.startsAt); + expectType(entitlement.endsAt); + } + + client.on(Events.InteractionCreate, async interaction => { + expectType>(interaction.entitlements); + + if (interaction.isRepliable()) { + await interaction.sendPremiumRequired(); + } + }); +} From d0d3be89e2a20fd42c6cc60bf38752cfc8b72cf6 Mon Sep 17 00:00:00 2001 From: Almeida Date: Sun, 10 Dec 2023 11:39:05 +0000 Subject: [PATCH 2/4] types: readonly array Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com> --- packages/discord.js/typings/index.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index ed5a7c525f5d..b12a5279455f 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -4079,7 +4079,7 @@ export interface FetchEntitlementOptions { limit?: number; guild?: GuildResolvable; user?: UserResolvable; - skus?: SKUResolvable[]; + skus?: readonly SKUResolvable[]; excludeEnded?: boolean; cache?: boolean; before?: Snowflake; From d0e6b5f1744189e477e53ce9dcf427377aec93fe Mon Sep 17 00:00:00 2001 From: Almeida Date: Mon, 18 Dec 2023 22:09:00 +0000 Subject: [PATCH 3/4] fix: requested changes Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com> --- packages/discord.js/src/managers/EntitlementManager.js | 4 ++-- packages/discord.js/src/structures/ClientApplication.js | 2 +- packages/discord.js/typings/index.d.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/discord.js/src/managers/EntitlementManager.js b/packages/discord.js/src/managers/EntitlementManager.js index d4c536a87fae..a392b05b93ca 100644 --- a/packages/discord.js/src/managers/EntitlementManager.js +++ b/packages/discord.js/src/managers/EntitlementManager.js @@ -43,7 +43,7 @@ class EntitlementManager extends CachedManager { * @property {number} [limit] The maximum number of entitlements to fetch * @property {GuildResolvable} [guild] The guild to fetch entitlements for * @property {UserResolvable} [user] The user to fetch entitlements for - * @property {SKUResolvable[]} [skus] The skus to fetch entitlements for + * @property {SKUResolvable[]} [skus] The SKUs to fetch entitlements for * @property {boolean} [excludeEnded] Whether to exclude ended entitlements * @property {boolean} [cache=true] Whether to cache the fetched entitlements * @property {Snowflake} [before] Consider only entitlements before this entitlement id @@ -78,7 +78,7 @@ class EntitlementManager extends CachedManager { * Options used to create a test entitlement * Either `guild` or `user` must be provided, but not both * @typedef {Object} EntitlementCreateOptions - * @property {SKUResolvable} sku The id of the sku to create the entitlement for + * @property {SKUResolvable} sku The id of the SKU to create the entitlement for * @property {GuildResolvable} [guild] The guild to create the entitlement for * @property {UserResolvable} [user] The user to create the entitlement for */ diff --git a/packages/discord.js/src/structures/ClientApplication.js b/packages/discord.js/src/structures/ClientApplication.js index 84a1c0ac716c..64762a1df6da 100644 --- a/packages/discord.js/src/structures/ClientApplication.js +++ b/packages/discord.js/src/structures/ClientApplication.js @@ -298,7 +298,7 @@ class ClientApplication extends Application { } /** - * Gets this application's skus + * Gets this application's SKUs * @returns {Promise>} */ async fetchSKUs() { diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index b12a5279455f..2a8141de532b 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -4075,7 +4075,7 @@ export interface UserEntitlementCreateOptions { user: UserResolvable; } -export interface FetchEntitlementOptions { +export interface FetchEntitlementsOptions { limit?: number; guild?: GuildResolvable; user?: UserResolvable; @@ -4088,7 +4088,7 @@ export interface FetchEntitlementOptions { export class EntitlementManager extends CachedManager { private constructor(client: Client, iterable: Iterable); - public fetch(options?: FetchEntitlementOptions): Promise>; + public fetch(options?: FetchEntitlementsOptions): Promise>; public createTest(options: GuildEntitlementCreateOptions | UserEntitlementCreateOptions): Promise; public deleteTest(entitlement: EntitlementResolvable): Promise; } From d6599a63398eefe639d264e15a41ccd330cc2cc6 Mon Sep 17 00:00:00 2001 From: Almeida Date: Mon, 18 Dec 2023 22:27:11 +0000 Subject: [PATCH 4/4] fix: core client types --- packages/core/src/client.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index b6e996968533..f39fa434044e 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -198,9 +198,8 @@ export class Client extends AsyncEventEmitter { this.gateway.on(WebSocketShardEvents.Dispatch, ({ data: dispatch, shardId }) => { this.emit( - // TODO: move this expect-error down to the next line once entitlements get merged, so missing dispatch types result in errors - // @ts-expect-error event props can't be resolved properly, but they are correct dispatch.t, + // @ts-expect-error event props can't be resolved properly, but they are correct this.wrapIntrinsicProps(dispatch.d, shardId), ); });