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>
This commit is contained in:
Jiralite 2024-07-04 19:57:35 +01:00 committed by GitHub
parent 093ac924ae
commit 4f59b740d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 154 additions and 14 deletions

View file

@ -50,6 +50,11 @@ describe('Button Components', () => {
button.toJSON(); button.toJSON();
}).not.toThrowError(); }).not.toThrowError();
expect(() => {
const button = buttonComponent().setSKUId('123456789012345678').setStyle(ButtonStyle.Premium);
button.toJSON();
}).not.toThrowError();
expect(() => buttonComponent().setURL('https://google.com')).not.toThrowError(); expect(() => buttonComponent().setURL('https://google.com')).not.toThrowError();
}); });
@ -101,6 +106,47 @@ describe('Button Components', () => {
button.toJSON(); button.toJSON();
}).toThrowError(); }).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 // @ts-expect-error: Invalid style
expect(() => buttonComponent().setStyle(24)).toThrowError(); expect(() => buttonComponent().setStyle(24)).toThrowError();
expect(() => buttonComponent().setLabel(longStr)).toThrowError(); expect(() => buttonComponent().setLabel(longStr)).toThrowError();

View file

@ -81,21 +81,36 @@ export function validateRequiredButtonParameters(
label?: string, label?: string,
emoji?: APIMessageComponentEmoji, emoji?: APIMessageComponentEmoji,
customId?: string, customId?: string,
skuId?: string,
url?: string, url?: string,
) { ) {
if (url && customId) { if (style === ButtonStyle.Premium) {
throw new RangeError('URL and custom id are mutually exclusive'); 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.');
}
if (style === ButtonStyle.Link) { } else {
if (!url) { if (skuId) {
throw new RangeError('Link buttons must have a url'); 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.');
}
} 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');
} }
} }

View file

@ -6,6 +6,7 @@ import {
type APIButtonComponentWithURL, type APIButtonComponentWithURL,
type APIMessageComponentEmoji, type APIMessageComponentEmoji,
type ButtonStyle, type ButtonStyle,
type Snowflake,
} from 'discord-api-types/v10'; } from 'discord-api-types/v10';
import { import {
buttonLabelValidator, buttonLabelValidator,
@ -89,6 +90,17 @@ export class ButtonBuilder extends ComponentBuilder<APIButtonComponent> {
return this; 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. * Sets the emoji to display on this button.
* *
@ -128,6 +140,7 @@ export class ButtonBuilder extends ComponentBuilder<APIButtonComponent> {
(this.data as Exclude<APIButtonComponent, APIButtonComponentWithSKUId>).label, (this.data as Exclude<APIButtonComponent, APIButtonComponentWithSKUId>).label,
(this.data as Exclude<APIButtonComponent, APIButtonComponentWithSKUId>).emoji, (this.data as Exclude<APIButtonComponent, APIButtonComponentWithSKUId>).emoji,
(this.data as APIButtonComponentWithCustomId).custom_id, (this.data as APIButtonComponentWithCustomId).custom_id,
(this.data as APIButtonComponentWithSKUId).sku_id,
(this.data as APIButtonComponentWithURL).url, (this.data as APIButtonComponentWithURL).url,
); );

View file

@ -258,6 +258,7 @@ export class InteractionsAPI {
* @param interactionId - The id of the interaction * @param interactionId - The id of the interaction
* @param interactionToken - The token of the interaction * @param interactionToken - The token of the interaction
* @param options - The options for sending the premium required response * @param options - The options for sending the premium required response
* @deprecated Sending a premium-style button is the new Discord behaviour.
*/ */
public async sendPremiumRequired( public async sendPremiumRequired(
interactionId: Snowflake, interactionId: Snowflake,

View file

@ -1,5 +1,6 @@
'use strict'; 'use strict';
const { deprecate } = require('node:util');
const { isJSONEncodable } = require('@discordjs/util'); const { isJSONEncodable } = require('@discordjs/util');
const { InteractionResponseType, MessageFlags, Routes, InteractionType } = require('discord-api-types/v10'); const { InteractionResponseType, MessageFlags, Routes, InteractionType } = require('discord-api-types/v10');
const { DiscordjsError, ErrorCodes } = require('../../errors'); const { DiscordjsError, ErrorCodes } = require('../../errors');
@ -266,6 +267,7 @@ class InteractionResponses {
/** /**
* Responds to the interaction with an upgrade button. * Responds to the interaction with an upgrade button.
* <info>Only available for applications with monetization enabled.</info> * <info>Only available for applications with monetization enabled.</info>
* @deprecated Sending a premium-style button is the new Discord behaviour.
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async sendPremiumRequired() { 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; module.exports = InteractionResponses;

View file

@ -604,6 +604,7 @@ export abstract class CommandInteraction<Cached extends CacheType = CacheType> e
| ModalComponentData | ModalComponentData
| APIModalInteractionResponseCallbackData, | APIModalInteractionResponseCallbackData,
): Promise<void>; ): Promise<void>;
/** @deprecated Sending a premium-style button is the new Discord behaviour. */
public sendPremiumRequired(): Promise<void>; public sendPremiumRequired(): Promise<void>;
public awaitModalSubmit( public awaitModalSubmit(
options: AwaitModalSubmitOptions<ModalSubmitInteraction>, options: AwaitModalSubmitOptions<ModalSubmitInteraction>,
@ -2261,6 +2262,7 @@ export class MessageComponentInteraction<Cached extends CacheType = CacheType> e
| ModalComponentData | ModalComponentData
| APIModalInteractionResponseCallbackData, | APIModalInteractionResponseCallbackData,
): Promise<void>; ): Promise<void>;
/** @deprecated Sending a premium-style button is the new Discord behaviour. */
public sendPremiumRequired(): Promise<void>; public sendPremiumRequired(): Promise<void>;
public awaitModalSubmit( public awaitModalSubmit(
options: AwaitModalSubmitOptions<ModalSubmitInteraction>, options: AwaitModalSubmitOptions<ModalSubmitInteraction>,
@ -2460,6 +2462,7 @@ export class ModalSubmitInteraction<Cached extends CacheType = CacheType> extend
options: InteractionDeferUpdateOptions & { fetchReply: true }, options: InteractionDeferUpdateOptions & { fetchReply: true },
): Promise<Message<BooleanCache<Cached>>>; ): Promise<Message<BooleanCache<Cached>>>;
public deferUpdate(options?: InteractionDeferUpdateOptions): Promise<InteractionResponse<BooleanCache<Cached>>>; public deferUpdate(options?: InteractionDeferUpdateOptions): Promise<InteractionResponse<BooleanCache<Cached>>>;
/** @deprecated Sending a premium-style button is the new Discord behaviour. */
public sendPremiumRequired(): Promise<void>; public sendPremiumRequired(): Promise<void>;
public inGuild(): this is ModalSubmitInteraction<'raw' | 'cached'>; public inGuild(): this is ModalSubmitInteraction<'raw' | 'cached'>;
public inCachedGuild(): this is ModalSubmitInteraction<'cached'>; public inCachedGuild(): this is ModalSubmitInteraction<'cached'>;

View file

@ -206,7 +206,7 @@ import {
MentionableSelectMenuComponent, MentionableSelectMenuComponent,
Poll, Poll,
} from '.'; } from '.';
import { expectAssignable, expectNotAssignable, expectNotType, expectType } from 'tsd'; import { expectAssignable, expectDeprecated, expectNotAssignable, expectNotType, expectType } from 'tsd';
import type { ContextMenuCommandBuilder, SlashCommandBuilder } from '@discordjs/builders'; import type { ContextMenuCommandBuilder, SlashCommandBuilder } from '@discordjs/builders';
import { ReadonlyCollection } from '@discordjs/collection'; import { ReadonlyCollection } from '@discordjs/collection';
@ -1763,6 +1763,7 @@ client.on('interactionCreate', async interaction => {
expectType<AnySelectMenuInteraction | ButtonInteraction>(interaction); expectType<AnySelectMenuInteraction | ButtonInteraction>(interaction);
expectType<MessageActionRowComponent | APIButtonComponent | APISelectMenuComponent>(interaction.component); expectType<MessageActionRowComponent | APIButtonComponent | APISelectMenuComponent>(interaction.component);
expectType<Message>(interaction.message); expectType<Message>(interaction.message);
expectDeprecated(interaction.sendPremiumRequired());
if (interaction.inCachedGuild()) { if (interaction.inCachedGuild()) {
expectAssignable<MessageComponentInteraction>(interaction); expectAssignable<MessageComponentInteraction>(interaction);
expectType<MessageActionRowComponent>(interaction.component); expectType<MessageActionRowComponent>(interaction.component);
@ -1950,6 +1951,7 @@ client.on('interactionCreate', async interaction => {
interaction.type === InteractionType.ApplicationCommand && interaction.type === InteractionType.ApplicationCommand &&
interaction.commandType === ApplicationCommandType.ChatInput interaction.commandType === ApplicationCommandType.ChatInput
) { ) {
expectDeprecated(interaction.sendPremiumRequired());
if (interaction.inRawGuild()) { if (interaction.inRawGuild()) {
expectNotAssignable<Interaction<'cached'>>(interaction); expectNotAssignable<Interaction<'cached'>>(interaction);
expectAssignable<ChatInputCommandInteraction>(interaction); expectAssignable<ChatInputCommandInteraction>(interaction);
@ -2073,6 +2075,10 @@ client.on('interactionCreate', async interaction => {
expectType<Promise<Message>>(interaction.followUp({ content: 'a' })); expectType<Promise<Message>>(interaction.followUp({ content: 'a' }));
} }
} }
if (interaction.isModalSubmit()) {
expectDeprecated(interaction.sendPremiumRequired());
}
}); });
declare const shard: Shard; declare const shard: Shard;

View file

@ -2,6 +2,7 @@
import { URL } from 'node:url'; import { URL } from 'node:url';
import { describe, test, expect, vitest } from 'vitest'; import { describe, test, expect, vitest } from 'vitest';
import { import {
applicationDirectory,
chatInputApplicationCommandMention, chatInputApplicationCommandMention,
blockQuote, blockQuote,
bold, 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', () => { describe('Faces', () => {
test('GIVEN Faces.Shrug THEN returns "¯\\_(ツ)_/¯"', () => { test('GIVEN Faces.Shrug THEN returns "¯\\_(ツ)_/¯"', () => {
expect<'¯\\_(ツ)_/¯'>(Faces.Shrug).toEqual('¯\\_(ツ)_/¯'); expect<'¯\\_(ツ)_/¯'>(Faces.Shrug).toEqual('¯\\_(ツ)_/¯');

View file

@ -615,6 +615,39 @@ export function time(timeOrSeconds?: Date | number, style?: TimestampStylesStrin
return typeof style === 'string' ? `<t:${timeOrSeconds}:${style}>` : `<t:${timeOrSeconds}>`; return typeof style === 'string' ? `<t:${timeOrSeconds}:${style}>` : `<t:${timeOrSeconds}>`;
} }
/**
* Formats an application directory link.
*
* @typeParam ApplicationId - This is inferred by the supplied application id
* @param applicationId - The application id
*/
export function applicationDirectory<ApplicationId extends Snowflake>(
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 extends Snowflake, SKUId extends Snowflake>(
applicationId: ApplicationId,
skuId: SKUId,
): `https://discord.com/application-directory/${ApplicationId}/store/${SKUId}`;
export function applicationDirectory<ApplicationId extends Snowflake, SKUId extends Snowflake>(
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} * The {@link https://discord.com/developers/docs/reference#message-formatting-timestamp-styles | message formatting timestamp styles}
* supported by Discord. * supported by Discord.