feat: premium application subscriptions (#9907)

* feat: premium application subscriptions

* types: readonly array

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>

* fix: requested changes

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>

* fix: core client types

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Almeida
2023-12-24 15:49:58 +00:00
committed by GitHub
parent 520d6f64dd
commit c4fcee3ef6
33 changed files with 914 additions and 10 deletions

View File

@@ -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);

View File

@@ -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<RequestData, 'signal'> = {},
) {
await this.rest.post(Routes.interactionCallback(interactionId, interactionToken), {
auth: false,
body: {
type: InteractionResponseType.PremiumRequired,
} satisfies APIPremiumRequiredInteractionResponse,
signal,
});
}
}

View File

@@ -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<RequestData, 'signal'> = {}) {
return this.rest.get(Routes.skus(applicationId), { signal }) as Promise<RESTGetAPISKUsResult>;
}
/**
* 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<RequestData, 'signal'> = {},
) {
return this.rest.get(Routes.entitlements(applicationId), {
signal,
query: makeURLSearchParams(query),
}) as Promise<RESTGetAPIEntitlementsResult>;
}
/**
* 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<RequestData, 'signal'> = {},
) {
return this.rest.post(Routes.entitlements(applicationId), {
body,
signal,
}) as Promise<RESTPostAPIEntitlementResult>;
}
/**
* 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<RequestData, 'signal'> = {},
) {
await this.rest.delete(Routes.entitlement(applicationId, entitlementId), { signal });
}
}

View File

@@ -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<GatewayChannelDeleteDispatchData>];
[GatewayDispatchEvents.ChannelPinsUpdate]: [WithIntrinsicProps<GatewayChannelPinsUpdateDispatchData>];
[GatewayDispatchEvents.ChannelUpdate]: [WithIntrinsicProps<GatewayChannelUpdateDispatchData>];
[GatewayDispatchEvents.EntitlementCreate]: [WithIntrinsicProps<GatewayEntitlementCreateDispatchData>];
[GatewayDispatchEvents.EntitlementDelete]: [WithIntrinsicProps<GatewayEntitlementDeleteDispatchData>];
[GatewayDispatchEvents.EntitlementUpdate]: [WithIntrinsicProps<GatewayEntitlementUpdateDispatchData>];
[GatewayDispatchEvents.GuildAuditLogEntryCreate]: [WithIntrinsicProps<GatewayGuildAuditLogEntryCreateDispatchData>];
[GatewayDispatchEvents.GuildBanAdd]: [WithIntrinsicProps<GatewayGuildBanAddDispatchData>];
[GatewayDispatchEvents.GuildBanRemove]: [WithIntrinsicProps<GatewayGuildBanRemoveDispatchData>];
@@ -192,9 +198,8 @@ export class Client extends AsyncEventEmitter<MappedEvents> {
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),
);
});

View File

@@ -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'));

View File

@@ -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;

View File

@@ -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.
* <warn>Entitlements are not deleted when they expire.
* This is only triggered when Discord issues a refund or deletes the entitlement manually.</warn>
* @event Client#entitlementDelete
* @param {Entitlement} entitlement The entitlement that was deleted
*/
client.emit(Events.EntitlementDelete, entitlement);
return {};
}
}
module.exports = EntitlementDeleteAction;

View File

@@ -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;

View File

@@ -0,0 +1,5 @@
'use strict';
module.exports = (client, packet) => {
client.actions.EntitlementCreate.handle(packet.d);
};

View File

@@ -0,0 +1,5 @@
'use strict';
module.exports = (client, packet) => {
client.actions.EntitlementDelete.handle(packet.d);
};

View File

@@ -0,0 +1,5 @@
'use strict';
module.exports = (client, packet) => {
client.actions.EntitlementUpdate.handle(packet.d);
};

View File

@@ -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')],

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<Snowflake, Entitlement>}
* @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
* <warn>If both `before` and `after` are provided, only `before` is respected</warn>
*/
/**
* Fetches entitlements for this application
* @param {FetchEntitlementsOptions} [options={}] Options for fetching the entitlements
* @returns {Promise<Collection<Snowflake, Entitlement>>}
*/
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
* <info>Either `guild` or `user` must be provided, but not both</info>
* @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<Entitlement>}
*/
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<void>}
*/
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;

View File

@@ -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<Snowflake, Entitlement>}
*/
this.entitlements = data.entitlements.reduce(
(coll, entitlement) => coll.set(entitlement.id, this.client.application.entitlements._add(entitlement)),
new Collection(),
);
}
/**

View File

@@ -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<Collection<Snowflake, SKU>>}
*/
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;

View File

@@ -153,6 +153,7 @@ class CommandInteraction extends BaseInteraction {
deleteReply() {}
followUp() {}
showModal() {}
sendPremiumRequired() {}
awaitModalSubmit() {}
}

View File

@@ -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
* <info>This is only `null` for test entitlements</info>
* @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
* <info>This is only `null` for test entitlements</info>
* @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
* <info>This is only `null` for test entitlements</info>
* @type {?Date}
*/
get startsAt() {
return this.startsTimestamp && new Date(this.startsTimestamp);
}
/**
* The end date at which this entitlement is no longer valid
* <info>This is only `null` for test entitlements</info>
* @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<User>}
*/
fetchUser() {
return this.client.users.fetch(this.userId);
}
}
exports.Entitlement = Entitlement;

View File

@@ -99,6 +99,7 @@ class MessageComponentInteraction extends BaseInteraction {
deferUpdate() {}
update() {}
showModal() {}
sendPremiumRequired() {}
awaitModalSubmit() {}
}

View File

@@ -118,6 +118,7 @@ class ModalSubmitInteraction extends BaseInteraction {
followUp() {}
deferUpdate() {}
update() {}
sendPremiumRequired() {}
}
InteractionResponses.applyToClass(ModalSubmitInteraction, 'showModal');

View File

@@ -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<SKUFlagsBitField>}
*/
this.flags = new SKUFlagsBitField(data.flags).freeze();
}
}
exports.SKU = SKU;

View File

@@ -262,6 +262,22 @@ class InteractionResponses {
this.replied = true;
}
/**
* Responds to the interaction with an upgrade button.
* <info>Only available for applications with monetization enabled.</info>
* @returns {Promise<void>}
*/
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',
];

View File

@@ -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}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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;

View File

@@ -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.

View File

@@ -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');

View File

@@ -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);

View File

@@ -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<Cached extends CacheType = CacheType> e
| ModalComponentData
| APIModalInteractionResponseCallbackData,
): Promise<void>;
public sendPremiumRequired(): Promise<void>;
public awaitModalSubmit(
options: AwaitModalSubmitOptions<ModalSubmitInteraction>,
): Promise<ModalSubmitInteraction<Cached>>;
@@ -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<ClientApplication>;
public fetch(): Promise<ClientApplication>;
public fetchRoleConnectionMetadataRecords(): Promise<ApplicationRoleConnectionMetadata[]>;
public fetchSKUs(): Promise<Collection<Snowflake, SKU>>;
public editRoleConnectionMetadataRecords(
records: ApplicationRoleConnectionMetadataEditOptions[],
): Promise<ApplicationRoleConnectionMetadata[]>;
@@ -1305,6 +1313,32 @@ export class Emoji extends Base {
public toString(): string;
}
export class Entitlement extends Base {
private constructor(client: Client<true>, 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<User>;
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<true>, data: RawGuildData);
private _sortedRoles(): Collection<Snowflake, Role>;
@@ -1829,6 +1863,7 @@ export class BaseInteraction<Cached extends CacheType = CacheType> extends Base
public memberPermissions: CacheTypeReducer<Cached, Readonly<PermissionsBitField>>;
public locale: Locale;
public guildLocale: CacheTypeReducer<Cached, Locale>;
public entitlements: Collection<Snowflake, Entitlement>;
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<Cached extends CacheType = CacheType> e
| ModalComponentData
| APIModalInteractionResponseCallbackData,
): Promise<void>;
public sendPremiumRequired(): Promise<void>;
public awaitModalSubmit(
options: AwaitModalSubmitOptions<ModalSubmitInteraction>,
): Promise<ModalSubmitInteraction<Cached>>;
@@ -2376,6 +2412,7 @@ export class ModalSubmitInteraction<Cached extends CacheType = CacheType> extend
options: InteractionDeferUpdateOptions & { fetchReply: true },
): Promise<Message<BooleanCache<Cached>>>;
public deferUpdate(options?: InteractionDeferUpdateOptions): Promise<InteractionResponse<BooleanCache<Cached>>>;
public sendPremiumRequired(): Promise<void>;
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<true>, data: APISKU);
public id: Snowflake;
public type: SKUType;
public applicationId: Snowflake;
public name: string;
public slug: string;
public flags: Readonly<SKUFlagsBitField>;
}
export type SKUFlagsString = keyof typeof SKUFlags;
export class SKUFlagsBitField extends BitField<SKUFlagsString> {
public static FLAGS: typeof SKUFlags;
public static resolve(bit?: BitFieldResolvable<SKUFlagsString, number>): 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<SweeperDefinitions['emojis'][0], SweeperDefinitions['emojis'][1]>,
): number;
public sweepEntitlements(
filter: CollectionSweepFilter<SweeperDefinitions['entitlements'][0], SweeperDefinitions['entitlements'][1]>,
): number;
public sweepInvites(
filter: CollectionSweepFilter<SweeperDefinitions['invites'][0], SweeperDefinitions['invites'][1]>,
): number;
@@ -3281,6 +3338,7 @@ export function transformResolved<Cached extends CacheType>(
supportingData: SupportingInteractionResolvedData,
data?: APIApplicationCommandInteractionData['resolved'],
): CommandInteractionResolvedData<Cached>;
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<Snowflake, Channel, ChannelRes
public fetch(id: Snowflake, options?: FetchChannelOptions): Promise<Channel | null>;
}
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 FetchEntitlementsOptions {
limit?: number;
guild?: GuildResolvable;
user?: UserResolvable;
skus?: readonly SKUResolvable[];
excludeEnded?: boolean;
cache?: boolean;
before?: Snowflake;
after?: Snowflake;
}
export class EntitlementManager extends CachedManager<Snowflake, Entitlement, EntitlementResolvable> {
private constructor(client: Client<true>, iterable: Iterable<APIEntitlement>);
public fetch(options?: FetchEntitlementsOptions): Promise<Collection<Snowflake, Entitlement>>;
public createTest(options: GuildEntitlementCreateOptions | UserEntitlementCreateOptions): Promise<Entitlement>;
public deleteTest(entitlement: EntitlementResolvable): Promise<void>;
}
export interface FetchGuildApplicationCommandFetchOptions extends Omit<FetchApplicationCommandOptions, 'guildId'> {}
export class GuildApplicationCommandManager extends ApplicationCommandManager<ApplicationCommand, {}, Guild> {
@@ -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];

View File

@@ -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<PartialEmojiOnlyId>(resolvePartialEmoji('12345678901234567'));
expectType<PartialEmoji | null>(resolvePartialEmoji(emoji));
}
declare const application: ClientApplication;
declare const entitlement: Entitlement;
declare const sku: SKU;
{
expectType<Collection<Snowflake, SKU>>(await application.fetchSKUs());
expectType<Collection<Snowflake, Entitlement>>(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<boolean>(entitlement.isActive());
if (entitlement.isUserSubscription()) {
expectType<Snowflake>(entitlement.userId);
expectType<User>(await entitlement.fetchUser());
expectType<null>(entitlement.guildId);
expectType<null>(entitlement.guild);
await application.entitlements.deleteTest(entitlement);
} else if (entitlement.isGuildSubscription()) {
expectType<Snowflake>(entitlement.guildId);
expectType<Guild>(entitlement.guild);
await application.entitlements.deleteTest(entitlement);
}
if (entitlement.isTest()) {
expectType<null>(entitlement.startsTimestamp);
expectType<null>(entitlement.endsTimestamp);
expectType<null>(entitlement.startsAt);
expectType<null>(entitlement.endsAt);
}
client.on(Events.InteractionCreate, async interaction => {
expectType<Collection<Snowflake, Entitlement>>(interaction.entitlements);
if (interaction.isRepliable()) {
await interaction.sendPremiumRequired();
}
});
}