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

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