From 4f59b740d01b9ff2213949708a36e17da32b89c3 Mon Sep 17 00:00:00 2001 From: Jiralite <33201955+Jiralite@users.noreply.github.com> Date: Thu, 4 Jul 2024 19:57:35 +0100 Subject: [PATCH] feat: Premium buttons (#10353) * feat: premium buttons * docs: deprecation string * feat(InteractionResponses): add deprecation message * feat(builders): add tests * chore: remove @ts-expect-errors * test: update method name * refactor(formatters): stricter types * docs: deprecate method in typings --------- Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .../__tests__/components/button.test.ts | 46 +++++++++++++++++++ .../builders/src/components/Assertions.ts | 37 ++++++++++----- .../builders/src/components/button/Button.ts | 13 ++++++ packages/core/src/api/interactions.ts | 1 + .../interfaces/InteractionResponses.js | 8 ++++ packages/discord.js/typings/index.d.ts | 3 ++ packages/discord.js/typings/index.test-d.ts | 8 +++- .../formatters/__tests__/formatters.test.ts | 15 ++++++ packages/formatters/src/formatters.ts | 33 +++++++++++++ 9 files changed, 152 insertions(+), 12 deletions(-) diff --git a/packages/builders/__tests__/components/button.test.ts b/packages/builders/__tests__/components/button.test.ts index 29da7b4720b0..0eb5134d4312 100644 --- a/packages/builders/__tests__/components/button.test.ts +++ b/packages/builders/__tests__/components/button.test.ts @@ -50,6 +50,11 @@ describe('Button Components', () => { button.toJSON(); }).not.toThrowError(); + expect(() => { + const button = buttonComponent().setSKUId('123456789012345678').setStyle(ButtonStyle.Premium); + button.toJSON(); + }).not.toThrowError(); + expect(() => buttonComponent().setURL('https://google.com')).not.toThrowError(); }); @@ -101,6 +106,47 @@ describe('Button Components', () => { button.toJSON(); }).toThrowError(); + expect(() => { + const button = buttonComponent().setStyle(ButtonStyle.Primary).setSKUId('123456789012345678'); + button.toJSON(); + }).toThrowError(); + + expect(() => { + const button = buttonComponent() + .setStyle(ButtonStyle.Secondary) + .setLabel('button') + .setSKUId('123456789012345678'); + + button.toJSON(); + }).toThrowError(); + + expect(() => { + const button = buttonComponent() + .setStyle(ButtonStyle.Success) + .setEmoji({ name: '😇' }) + .setSKUId('123456789012345678'); + + button.toJSON(); + }).toThrowError(); + + expect(() => { + const button = buttonComponent() + .setStyle(ButtonStyle.Danger) + .setCustomId('test') + .setSKUId('123456789012345678'); + + button.toJSON(); + }).toThrowError(); + + expect(() => { + const button = buttonComponent() + .setStyle(ButtonStyle.Link) + .setURL('https://google.com') + .setSKUId('123456789012345678'); + + button.toJSON(); + }).toThrowError(); + // @ts-expect-error: Invalid style expect(() => buttonComponent().setStyle(24)).toThrowError(); expect(() => buttonComponent().setLabel(longStr)).toThrowError(); diff --git a/packages/builders/src/components/Assertions.ts b/packages/builders/src/components/Assertions.ts index 960efd706c7c..793e6b5bca44 100644 --- a/packages/builders/src/components/Assertions.ts +++ b/packages/builders/src/components/Assertions.ts @@ -81,21 +81,36 @@ export function validateRequiredButtonParameters( label?: string, emoji?: APIMessageComponentEmoji, customId?: string, + skuId?: string, url?: string, ) { - if (url && customId) { - throw new RangeError('URL and custom id are mutually exclusive'); - } + if (style === ButtonStyle.Premium) { + if (!skuId) { + throw new RangeError('Premium buttons must have an SKU id.'); + } - if (!label && !emoji) { - throw new RangeError('Buttons must have a label and/or an emoji'); - } + if (customId || label || url || emoji) { + throw new RangeError('Premium buttons cannot have a custom id, label, URL, or emoji.'); + } + } else { + if (skuId) { + throw new RangeError('Non-premium buttons must not have an SKU id.'); + } + + if (url && customId) { + throw new RangeError('URL and custom id are mutually exclusive.'); + } + + if (!label && !emoji) { + throw new RangeError('Non-premium buttons must have a label and/or an emoji.'); + } - if (style === ButtonStyle.Link) { - if (!url) { - throw new RangeError('Link buttons must have a url'); + if (style === ButtonStyle.Link) { + if (!url) { + throw new RangeError('Link buttons must have a URL.'); + } + } else if (url) { + throw new RangeError('Non-premium and non-link buttons cannot have a URL.'); } - } else if (url) { - throw new RangeError('Non-link buttons cannot have a url'); } } diff --git a/packages/builders/src/components/button/Button.ts b/packages/builders/src/components/button/Button.ts index b89172613ce2..cc36d80dabcb 100644 --- a/packages/builders/src/components/button/Button.ts +++ b/packages/builders/src/components/button/Button.ts @@ -6,6 +6,7 @@ import { type APIButtonComponentWithURL, type APIMessageComponentEmoji, type ButtonStyle, + type Snowflake, } from 'discord-api-types/v10'; import { buttonLabelValidator, @@ -89,6 +90,17 @@ export class ButtonBuilder extends ComponentBuilder { return this; } + /** + * Sets the SKU id that represents a purchasable SKU for this button. + * + * @remarks Only available when using premium-style buttons. + * @param skuId - The SKU id to use + */ + public setSKUId(skuId: Snowflake) { + (this.data as APIButtonComponentWithSKUId).sku_id = skuId; + return this; + } + /** * Sets the emoji to display on this button. * @@ -128,6 +140,7 @@ export class ButtonBuilder extends ComponentBuilder { (this.data as Exclude).label, (this.data as Exclude).emoji, (this.data as APIButtonComponentWithCustomId).custom_id, + (this.data as APIButtonComponentWithSKUId).sku_id, (this.data as APIButtonComponentWithURL).url, ); diff --git a/packages/core/src/api/interactions.ts b/packages/core/src/api/interactions.ts index 2cd0a9d18b1c..797e71caa6ea 100644 --- a/packages/core/src/api/interactions.ts +++ b/packages/core/src/api/interactions.ts @@ -258,6 +258,7 @@ export class InteractionsAPI { * @param interactionId - The id of the interaction * @param interactionToken - The token of the interaction * @param options - The options for sending the premium required response + * @deprecated Sending a premium-style button is the new Discord behaviour. */ public async sendPremiumRequired( interactionId: Snowflake, diff --git a/packages/discord.js/src/structures/interfaces/InteractionResponses.js b/packages/discord.js/src/structures/interfaces/InteractionResponses.js index c52ba133f5ef..9f711b517b49 100644 --- a/packages/discord.js/src/structures/interfaces/InteractionResponses.js +++ b/packages/discord.js/src/structures/interfaces/InteractionResponses.js @@ -1,5 +1,6 @@ 'use strict'; +const { deprecate } = require('node:util'); const { isJSONEncodable } = require('@discordjs/util'); const { InteractionResponseType, MessageFlags, Routes, InteractionType } = require('discord-api-types/v10'); const { DiscordjsError, ErrorCodes } = require('../../errors'); @@ -266,6 +267,7 @@ class InteractionResponses { /** * Responds to the interaction with an upgrade button. * Only available for applications with monetization enabled. + * @deprecated Sending a premium-style button is the new Discord behaviour. * @returns {Promise} */ async sendPremiumRequired() { @@ -337,4 +339,10 @@ class InteractionResponses { } } +InteractionResponses.prototype.sendPremiumRequired = deprecate( + InteractionResponses.prototype.sendPremiumRequired, + // eslint-disable-next-line max-len + 'InteractionResponses#sendPremiumRequired() is deprecated. Sending a premium-style button is the new Discord behaviour.', +); + module.exports = InteractionResponses; diff --git a/packages/discord.js/typings/index.d.ts b/packages/discord.js/typings/index.d.ts index 5545e4f31cb7..fd03fa0e5ad7 100644 --- a/packages/discord.js/typings/index.d.ts +++ b/packages/discord.js/typings/index.d.ts @@ -604,6 +604,7 @@ export abstract class CommandInteraction e | ModalComponentData | APIModalInteractionResponseCallbackData, ): Promise; + /** @deprecated Sending a premium-style button is the new Discord behaviour. */ public sendPremiumRequired(): Promise; public awaitModalSubmit( options: AwaitModalSubmitOptions, @@ -2261,6 +2262,7 @@ export class MessageComponentInteraction e | ModalComponentData | APIModalInteractionResponseCallbackData, ): Promise; + /** @deprecated Sending a premium-style button is the new Discord behaviour. */ public sendPremiumRequired(): Promise; public awaitModalSubmit( options: AwaitModalSubmitOptions, @@ -2460,6 +2462,7 @@ export class ModalSubmitInteraction extend options: InteractionDeferUpdateOptions & { fetchReply: true }, ): Promise>>; public deferUpdate(options?: InteractionDeferUpdateOptions): Promise>>; + /** @deprecated Sending a premium-style button is the new Discord behaviour. */ public sendPremiumRequired(): Promise; public inGuild(): this is ModalSubmitInteraction<'raw' | 'cached'>; public inCachedGuild(): this is ModalSubmitInteraction<'cached'>; diff --git a/packages/discord.js/typings/index.test-d.ts b/packages/discord.js/typings/index.test-d.ts index d9b212f23807..407f6023e8d2 100644 --- a/packages/discord.js/typings/index.test-d.ts +++ b/packages/discord.js/typings/index.test-d.ts @@ -206,7 +206,7 @@ import { MentionableSelectMenuComponent, Poll, } from '.'; -import { expectAssignable, expectNotAssignable, expectNotType, expectType } from 'tsd'; +import { expectAssignable, expectDeprecated, expectNotAssignable, expectNotType, expectType } from 'tsd'; import type { ContextMenuCommandBuilder, SlashCommandBuilder } from '@discordjs/builders'; import { ReadonlyCollection } from '@discordjs/collection'; @@ -1763,6 +1763,7 @@ client.on('interactionCreate', async interaction => { expectType(interaction); expectType(interaction.component); expectType(interaction.message); + expectDeprecated(interaction.sendPremiumRequired()); if (interaction.inCachedGuild()) { expectAssignable(interaction); expectType(interaction.component); @@ -1950,6 +1951,7 @@ client.on('interactionCreate', async interaction => { interaction.type === InteractionType.ApplicationCommand && interaction.commandType === ApplicationCommandType.ChatInput ) { + expectDeprecated(interaction.sendPremiumRequired()); if (interaction.inRawGuild()) { expectNotAssignable>(interaction); expectAssignable(interaction); @@ -2073,6 +2075,10 @@ client.on('interactionCreate', async interaction => { expectType>(interaction.followUp({ content: 'a' })); } } + + if (interaction.isModalSubmit()) { + expectDeprecated(interaction.sendPremiumRequired()); + } }); declare const shard: Shard; diff --git a/packages/formatters/__tests__/formatters.test.ts b/packages/formatters/__tests__/formatters.test.ts index 362543692333..645d9b817140 100644 --- a/packages/formatters/__tests__/formatters.test.ts +++ b/packages/formatters/__tests__/formatters.test.ts @@ -2,6 +2,7 @@ import { URL } from 'node:url'; import { describe, test, expect, vitest } from 'vitest'; import { + applicationDirectory, chatInputApplicationCommandMention, blockQuote, bold, @@ -313,6 +314,20 @@ describe('Message formatters', () => { }); }); + describe('applicationDirectory', () => { + test('GIVEN application id THEN returns application directory store', () => { + expect(applicationDirectory('123456789012345678')).toEqual( + 'https://discord.com/application-directory/123456789012345678/store', + ); + }); + + test('GIVEN application id AND SKU id THEN returns SKU within the application directory store', () => { + expect(applicationDirectory('123456789012345678', '123456789012345678')).toEqual( + 'https://discord.com/application-directory/123456789012345678/store/123456789012345678', + ); + }); + }); + describe('Faces', () => { test('GIVEN Faces.Shrug THEN returns "¯\\_(ツ)_/¯"', () => { expect<'¯\\_(ツ)_/¯'>(Faces.Shrug).toEqual('¯\\_(ツ)_/¯'); diff --git a/packages/formatters/src/formatters.ts b/packages/formatters/src/formatters.ts index eec15ed140a0..37bfbaf9a2c2 100644 --- a/packages/formatters/src/formatters.ts +++ b/packages/formatters/src/formatters.ts @@ -615,6 +615,39 @@ export function time(timeOrSeconds?: Date | number, style?: TimestampStylesStrin return typeof style === 'string' ? `` : ``; } +/** + * Formats an application directory link. + * + * @typeParam ApplicationId - This is inferred by the supplied application id + * @param applicationId - The application id + */ +export function applicationDirectory( + applicationId: ApplicationId, +): `https://discord.com/application-directory/${ApplicationId}/store`; + +/** + * Formats an application directory SKU link. + * + * @typeParam ApplicationId - This is inferred by the supplied application id + * @typeParam SKUId - This is inferred by the supplied SKU id + * @param applicationId - The application id + * @param skuId - The SKU id + */ +export function applicationDirectory( + applicationId: ApplicationId, + skuId: SKUId, +): `https://discord.com/application-directory/${ApplicationId}/store/${SKUId}`; + +export function applicationDirectory( + applicationId: ApplicationId, + skuId?: SKUId, +): + | `https://discord.com/application-directory/${ApplicationId}/store/${SKUId}` + | `https://discord.com/application-directory/${ApplicationId}/store` { + const url = `https://discord.com/application-directory/${applicationId}/store` as const; + return skuId ? `${url}/${skuId}` : url; +} + /** * The {@link https://discord.com/developers/docs/reference#message-formatting-timestamp-styles | message formatting timestamp styles} * supported by Discord.