mirror of
https://github.com/discordjs/discord.js.git
synced 2024-07-16 07:17:35 +12:00
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:
parent
093ac924ae
commit
4f59b740d0
|
@ -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();
|
||||||
|
|
|
@ -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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
3
packages/discord.js/typings/index.d.ts
vendored
3
packages/discord.js/typings/index.d.ts
vendored
|
@ -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'>;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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('¯\\_(ツ)_/¯');
|
||||||
|
|
|
@ -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.
|
||||||
|
|
Loading…
Reference in a new issue