From 108943a397876d4e1d8678c960292ff1cacdbf93 Mon Sep 17 00:00:00 2001 From: Danial Raza Date: Thu, 28 Nov 2024 09:18:44 +0100 Subject: [PATCH] feat: add subscriptions (#10541) * feat: add subscriptions * types: fix fetch options types * fix: correct properties in patch method * chore: requested changes Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com> * fix: correct export syntax * chore(Entitlement): mark `ends_at` as nullable` * types(FetchSubscriptionOptions): add missing `cache` option * Revert "types(FetchSubscriptionOptions): add missing `cache` option" This reverts commit ba472bdc599e1860754e59fce4806610f06ac682. * chore(Entitlement): mark `startsTimestamp` as nullable * fix: requested changes * docs(SubscriptionManager): correct return type --------- Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .../websocket/handlers/SUBSCRIPTION_CREATE.js | 14 +++ .../websocket/handlers/SUBSCRIPTION_DELETE.js | 16 +++ .../websocket/handlers/SUBSCRIPTION_UPDATE.js | 16 +++ .../src/client/websocket/handlers/index.js | 3 + packages/discord.js/src/index.js | 2 + .../src/managers/SubscriptionManager.js | 81 +++++++++++++ .../src/structures/ClientApplication.js | 7 ++ .../discord.js/src/structures/Entitlement.js | 8 +- .../discord.js/src/structures/Subscription.js | 109 ++++++++++++++++++ packages/discord.js/src/util/Events.js | 6 + packages/discord.js/typings/index.d.ts | 45 ++++++++ 11 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 packages/discord.js/src/client/websocket/handlers/SUBSCRIPTION_CREATE.js create mode 100644 packages/discord.js/src/client/websocket/handlers/SUBSCRIPTION_DELETE.js create mode 100644 packages/discord.js/src/client/websocket/handlers/SUBSCRIPTION_UPDATE.js create mode 100644 packages/discord.js/src/managers/SubscriptionManager.js create mode 100644 packages/discord.js/src/structures/Subscription.js diff --git a/packages/discord.js/src/client/websocket/handlers/SUBSCRIPTION_CREATE.js b/packages/discord.js/src/client/websocket/handlers/SUBSCRIPTION_CREATE.js new file mode 100644 index 000000000..4a5ad79e1 --- /dev/null +++ b/packages/discord.js/src/client/websocket/handlers/SUBSCRIPTION_CREATE.js @@ -0,0 +1,14 @@ +'use strict'; + +const Events = require('../../../util/Events'); + +module.exports = (client, { d: data }) => { + const subscription = client.application.subscriptions._add(data); + + /** + * Emitted whenever a subscription is created. + * @event Client#subscriptionCreate + * @param {Subscription} subscription The subscription that was created + */ + client.emit(Events.SubscriptionCreate, subscription); +}; diff --git a/packages/discord.js/src/client/websocket/handlers/SUBSCRIPTION_DELETE.js b/packages/discord.js/src/client/websocket/handlers/SUBSCRIPTION_DELETE.js new file mode 100644 index 000000000..dc3930335 --- /dev/null +++ b/packages/discord.js/src/client/websocket/handlers/SUBSCRIPTION_DELETE.js @@ -0,0 +1,16 @@ +'use strict'; + +const Events = require('../../../util/Events'); + +module.exports = (client, { d: data }) => { + const subscription = client.application.subscriptions._add(data, false); + + client.application.subscriptions.cache.delete(subscription.id); + + /** + * Emitted whenever a subscription is deleted. + * @event Client#subscriptionDelete + * @param {Subscription} subscription The subscription that was deleted + */ + client.emit(Events.SubscriptionDelete, subscription); +}; diff --git a/packages/discord.js/src/client/websocket/handlers/SUBSCRIPTION_UPDATE.js b/packages/discord.js/src/client/websocket/handlers/SUBSCRIPTION_UPDATE.js new file mode 100644 index 000000000..33a3c3fec --- /dev/null +++ b/packages/discord.js/src/client/websocket/handlers/SUBSCRIPTION_UPDATE.js @@ -0,0 +1,16 @@ +'use strict'; + +const Events = require('../../../util/Events'); + +module.exports = (client, { d: data }) => { + const oldSubscription = client.application.subscriptions.cache.get(data.id)?._clone() ?? null; + const newSubscription = client.application.subscriptions._add(data); + + /** + * Emitted whenever a subscription is updated - i.e. when a user's subscription renews. + * @event Client#subscriptionUpdate + * @param {?Subscription} oldSubscription The subscription before the update + * @param {Subscription} newSubscription The subscription after the update + */ + client.emit(Events.SubscriptionUpdate, oldSubscription, newSubscription); +}; diff --git a/packages/discord.js/src/client/websocket/handlers/index.js b/packages/discord.js/src/client/websocket/handlers/index.js index 24c3f86a4..eb1abdd28 100644 --- a/packages/discord.js/src/client/websocket/handlers/index.js +++ b/packages/discord.js/src/client/websocket/handlers/index.js @@ -52,6 +52,9 @@ const handlers = Object.fromEntries([ ['STAGE_INSTANCE_CREATE', require('./STAGE_INSTANCE_CREATE')], ['STAGE_INSTANCE_DELETE', require('./STAGE_INSTANCE_DELETE')], ['STAGE_INSTANCE_UPDATE', require('./STAGE_INSTANCE_UPDATE')], + ['SUBSCRIPTION_CREATE', require('./SUBSCRIPTION_CREATE')], + ['SUBSCRIPTION_DELETE', require('./SUBSCRIPTION_DELETE')], + ['SUBSCRIPTION_UPDATE', require('./SUBSCRIPTION_UPDATE')], ['THREAD_CREATE', require('./THREAD_CREATE')], ['THREAD_DELETE', require('./THREAD_DELETE')], ['THREAD_LIST_SYNC', require('./THREAD_LIST_SYNC')], diff --git a/packages/discord.js/src/index.js b/packages/discord.js/src/index.js index 7c5c17cd6..c04225c34 100644 --- a/packages/discord.js/src/index.js +++ b/packages/discord.js/src/index.js @@ -83,6 +83,7 @@ exports.ReactionManager = require('./managers/ReactionManager'); exports.ReactionUserManager = require('./managers/ReactionUserManager'); exports.RoleManager = require('./managers/RoleManager'); exports.StageInstanceManager = require('./managers/StageInstanceManager'); +exports.SubscriptionManager = require('./managers/SubscriptionManager').SubscriptionManager; exports.ThreadManager = require('./managers/ThreadManager'); exports.ThreadMemberManager = require('./managers/ThreadMemberManager'); exports.UserManager = require('./managers/UserManager'); @@ -193,6 +194,7 @@ exports.SKU = require('./structures/SKU').SKU; exports.StringSelectMenuOptionBuilder = require('./structures/StringSelectMenuOptionBuilder'); exports.StageChannel = require('./structures/StageChannel'); exports.StageInstance = require('./structures/StageInstance').StageInstance; +exports.Subscription = require('./structures/Subscription').Subscription; exports.Sticker = require('./structures/Sticker').Sticker; exports.StickerPack = require('./structures/StickerPack'); exports.Team = require('./structures/Team'); diff --git a/packages/discord.js/src/managers/SubscriptionManager.js b/packages/discord.js/src/managers/SubscriptionManager.js new file mode 100644 index 000000000..05f541df8 --- /dev/null +++ b/packages/discord.js/src/managers/SubscriptionManager.js @@ -0,0 +1,81 @@ +'use strict'; + +const { Collection } = require('@discordjs/collection'); +const { makeURLSearchParams } = require('@discordjs/rest'); +const { Routes } = require('discord-api-types/v10'); +const CachedManager = require('./CachedManager'); +const { DiscordjsTypeError, ErrorCodes } = require('../errors/index'); +const { Subscription } = require('../structures/Subscription'); +const { resolveSKUId } = require('../util/Util'); + +/** + * Manages API methods for subscriptions and stores their cache. + * @extends {CachedManager} + */ +class SubscriptionManager extends CachedManager { + constructor(client, iterable) { + super(client, Subscription, iterable); + } + + /** + * The cache of this manager + * @type {Collection} + * @name SubscriptionManager#cache + */ + + /** + * Options used to fetch a subscription + * @typedef {BaseFetchOptions} FetchSubscriptionOptions + * @property {SKUResolvable} sku The SKU to fetch the subscription for + * @property {Snowflake} subscriptionId The id of the subscription to fetch + */ + + /** + * Options used to fetch subscriptions + * @typedef {Object} FetchSubscriptionsOptions + * @property {Snowflake} [after] Consider only subscriptions after this subscription id + * @property {Snowflake} [before] Consider only subscriptions before this subscription id + * @property {number} [limit] The maximum number of subscriptions to fetch + * @property {SKUResolvable} sku The SKU to fetch subscriptions for + * @property {UserResolvable} user The user to fetch entitlements for + * If both `before` and `after` are provided, only `before` is respected + */ + + /** + * Fetches subscriptions for this application + * @param {FetchSubscriptionOptions|FetchSubscriptionsOptions} [options={}] Options for fetching the subscriptions + * @returns {Promise>} + */ + async fetch(options = {}) { + if (typeof options !== 'object') throw new DiscordjsTypeError(ErrorCodes.InvalidType, 'options', 'object', true); + + const { after, before, cache, limit, sku, subscriptionId, user } = options; + + const skuId = resolveSKUId(sku); + + if (!skuId) throw new DiscordjsTypeError(ErrorCodes.InvalidType, 'sku', 'SKUResolvable'); + + if (subscriptionId) { + const subscription = await this.client.rest.get(Routes.skuSubscription(skuId, subscriptionId)); + + return this._add(subscription, cache); + } + + const query = makeURLSearchParams({ + limit, + user_id: this.client.users.resolveId(user) ?? undefined, + sku_id: skuId, + before, + after, + }); + + const subscriptions = await this.client.rest.get(Routes.skuSubscriptions(skuId), { query }); + + return subscriptions.reduce( + (coll, subscription) => coll.set(subscription.id, this._add(subscription, cache)), + new Collection(), + ); + } +} + +exports.SubscriptionManager = SubscriptionManager; diff --git a/packages/discord.js/src/structures/ClientApplication.js b/packages/discord.js/src/structures/ClientApplication.js index 3149a5793..9e1492b42 100644 --- a/packages/discord.js/src/structures/ClientApplication.js +++ b/packages/discord.js/src/structures/ClientApplication.js @@ -9,6 +9,7 @@ const Application = require('./interfaces/Application'); const ApplicationCommandManager = require('../managers/ApplicationCommandManager'); const ApplicationEmojiManager = require('../managers/ApplicationEmojiManager'); const { EntitlementManager } = require('../managers/EntitlementManager'); +const { SubscriptionManager } = require('../managers/SubscriptionManager'); const ApplicationFlagsBitField = require('../util/ApplicationFlagsBitField'); const { resolveImage } = require('../util/DataResolver'); const PermissionsBitField = require('../util/PermissionsBitField'); @@ -44,6 +45,12 @@ class ClientApplication extends Application { * @type {EntitlementManager} */ this.entitlements = new EntitlementManager(this.client); + + /** + * The subscription manager for this application + * @type {SubscriptionManager} + */ + this.subscriptions = new SubscriptionManager(this.client); } _patch(data) { diff --git a/packages/discord.js/src/structures/Entitlement.js b/packages/discord.js/src/structures/Entitlement.js index 115873438..f7652d61b 100644 --- a/packages/discord.js/src/structures/Entitlement.js +++ b/packages/discord.js/src/structures/Entitlement.js @@ -73,10 +73,9 @@ class Entitlement extends Base { 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); + this.startsTimestamp = data.starts_at ? Date.parse(data.starts_at) : null; } else { this.startsTimestamp ??= null; } @@ -84,10 +83,9 @@ class Entitlement extends Base { 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); + this.endsTimestamp = data.ends_at ? Date.parse(data.ends_at) : null; } else { this.endsTimestamp ??= null; } @@ -114,7 +112,6 @@ class Entitlement extends Base { /** * The start date at which this entitlement is valid - * This is only `null` for test entitlements * @type {?Date} */ get startsAt() { @@ -123,7 +120,6 @@ class Entitlement extends Base { /** * The end date at which this entitlement is no longer valid - * This is only `null` for test entitlements * @type {?Date} */ get endsAt() { diff --git a/packages/discord.js/src/structures/Subscription.js b/packages/discord.js/src/structures/Subscription.js new file mode 100644 index 000000000..9a1766024 --- /dev/null +++ b/packages/discord.js/src/structures/Subscription.js @@ -0,0 +1,109 @@ +'use strict'; + +const Base = require('./Base'); + +/** + * Represents a Subscription + * @extends {Base} + */ +class Subscription extends Base { + constructor(client, data) { + super(client); + + /** + * The id of the subscription + * @type {Snowflake} + */ + this.id = data.id; + + /** + * The id of the user who subscribed + * @type {Snowflake} + */ + this.userId = data.user_id; + + this._patch(data); + } + + _patch(data) { + /** + * The SKU ids subscribed to + * @type {Snowflake[]} + */ + this.skuIds = data.sku_ids; + + /** + * The entitlement ids granted for this subscription + * @type {Snowflake[]} + */ + this.entitlementIds = data.entitlement_ids; + + /** + * The timestamp the current subscription period will start at + * @type {number} + */ + this.currentPeriodStartTimestamp = Date.parse(data.current_period_start); + + /** + * The timestamp the current subscription period will end at + * @type {number} + */ + this.currentPeriodEndTimestamp = Date.parse(data.current_period_end); + + /** + * The current status of the subscription + * @type {SubscriptionStatus} + */ + this.status = data.status; + + if ('canceled_at' in data) { + /** + * The timestamp of when the subscription was canceled + * @type {?number} + */ + this.canceledTimestamp = data.canceled_at ? Date.parse(data.canceled_at) : null; + } else { + this.canceledTimestamp ??= null; + } + + if ('country' in data) { + /** + * ISO 3166-1 alpha-2 country code of the payment source used to purchase the subscription. + * Missing unless queried with a private OAuth scope. + * @type {?string} + */ + this.country = data.country; + } else { + this.country ??= null; + } + } + + /** + * The time the subscription was canceled + * @type {?Date} + * @readonly + */ + get canceledAt() { + return this.canceledTimestamp && new Date(this.canceledTimestamp); + } + + /** + * The time the current subscription period will start at + * @type {Date} + * @readonly + */ + get currentPeriodStartAt() { + return new Date(this.currentPeriodStartTimestamp); + } + + /** + * The time the current subscription period will end at + * @type {Date} + * @readonly + */ + get currentPeriodEndAt() { + return new Date(this.currentPeriodEndTimestamp); + } +} + +exports.Subscription = Subscription; diff --git a/packages/discord.js/src/util/Events.js b/packages/discord.js/src/util/Events.js index a4b658e5c..7fb94a6bf 100644 --- a/packages/discord.js/src/util/Events.js +++ b/packages/discord.js/src/util/Events.js @@ -64,6 +64,9 @@ * @property {string} StageInstanceCreate stageInstanceCreate * @property {string} StageInstanceDelete stageInstanceDelete * @property {string} StageInstanceUpdate stageInstanceUpdate + * @property {string} SubscriptionCreate subscriptionCreate + * @property {string} SubscriptionUpdate subscriptionUpdate + * @property {string} SubscriptionDelete subscriptionDelete * @property {string} ThreadCreate threadCreate * @property {string} ThreadDelete threadDelete * @property {string} ThreadListSync threadListSync @@ -147,6 +150,9 @@ module.exports = { StageInstanceCreate: 'stageInstanceCreate', StageInstanceDelete: 'stageInstanceDelete', StageInstanceUpdate: 'stageInstanceUpdate', + SubscriptionCreate: 'subscriptionCreate', + SubscriptionUpdate: 'subscriptionUpdate', + SubscriptionDelete: 'subscriptionDelete', ThreadCreate: 'threadCreate', ThreadDelete: 'threadDelete', ThreadListSync: 'threadListSync', diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index ac5df23cb..dce9f783d 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -164,6 +164,8 @@ import { GuildScheduledEventRecurrenceRuleWeekday, GuildScheduledEventRecurrenceRuleMonth, GuildScheduledEventRecurrenceRuleFrequency, + APISubscription, + SubscriptionStatus, GatewaySendPayload, GatewayDispatchPayload, VoiceChannelEffectSendAnimationType, @@ -1053,6 +1055,7 @@ export class ClientApplication extends Application { public commands: ApplicationCommandManager; public emojis: ApplicationEmojiManager; public entitlements: EntitlementManager; + public subscriptions: SubscriptionManager; public guildId: Snowflake | null; public get guild(): Guild | null; public cover: string | null; @@ -3078,6 +3081,22 @@ export class SKUFlagsBitField extends BitField { public static resolve(bit?: BitFieldResolvable): number; } +export class Subscription extends Base { + private constructor(client: Client, data: APISubscription); + public id: Snowflake; + public userId: Snowflake; + public skuIds: Snowflake[]; + public entitlementIds: Snowflake[]; + public currentPeriodStartTimestamp: number; + public currentPeriodEndTimestamp: number; + public status: SubscriptionStatus; + public canceledTimestamp: number | null; + public country: string | null; + public get canceledAt(): Date | null; + public get currentPeriodStartAt(): Date; + public get currentPeriodEndAt(): Date; +} + export class StageChannel extends BaseGuildVoiceChannel { public get stageInstance(): StageInstance | null; public topic: string | null; @@ -4069,6 +4088,7 @@ export class ChannelManager extends CachedManager; } +export interface FetchSubscriptionOptions extends BaseFetchOptions { + sku: SKUResolvable; + subscriptionId: Snowflake; +} + +export interface FetchSubscriptionsOptions { + after?: Snowflake; + before?: Snowflake; + limit?: number; + sku: SKUResolvable; + user: UserResolvable; +} + +export class SubscriptionManager extends CachedManager { + private constructor(client: Client, iterable?: Iterable); + public fetch(options: FetchSubscriptionOptions): Promise; + public fetch(options: FetchSubscriptionsOptions): Promise>; +} + export interface FetchGuildApplicationCommandFetchOptions extends Omit {} export class GuildApplicationCommandManager extends ApplicationCommandManager { @@ -5193,6 +5232,9 @@ export interface ClientEvents { stickerCreate: [sticker: Sticker]; stickerDelete: [sticker: Sticker]; stickerUpdate: [oldSticker: Sticker, newSticker: Sticker]; + subscriptionCreate: [subscription: Subscription]; + subscriptionDelete: [subscription: Subscription]; + subscriptionUpdate: [oldSubscription: Subscription | null, newSubscription: Subscription]; guildScheduledEventCreate: [guildScheduledEvent: GuildScheduledEvent]; guildScheduledEventUpdate: [ oldGuildScheduledEvent: GuildScheduledEvent | PartialGuildScheduledEvent | null, @@ -5392,6 +5434,9 @@ export enum Events { StageInstanceCreate = 'stageInstanceCreate', StageInstanceUpdate = 'stageInstanceUpdate', StageInstanceDelete = 'stageInstanceDelete', + SubscriptionCreate = 'subscriptionCreate', + SubscriptionUpdate = 'subscriptionUpdate', + SubscriptionDelete = 'subscriptionDelete', GuildStickerCreate = 'stickerCreate', GuildStickerDelete = 'stickerDelete', GuildStickerUpdate = 'stickerUpdate',