Skip to content

Commit 4f59b74

Browse files
Jiralitekodiakhq[bot]
andauthoredJul 4, 2024··
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>
1 parent 093ac92 commit 4f59b74

File tree

9 files changed

+152
-12
lines changed

9 files changed

+152
-12
lines changed
 

‎packages/builders/__tests__/components/button.test.ts

+46
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ describe('Button Components', () => {
5050
button.toJSON();
5151
}).not.toThrowError();
5252

53+
expect(() => {
54+
const button = buttonComponent().setSKUId('123456789012345678').setStyle(ButtonStyle.Premium);
55+
button.toJSON();
56+
}).not.toThrowError();
57+
5358
expect(() => buttonComponent().setURL('https://google.com')).not.toThrowError();
5459
});
5560

@@ -101,6 +106,47 @@ describe('Button Components', () => {
101106
button.toJSON();
102107
}).toThrowError();
103108

109+
expect(() => {
110+
const button = buttonComponent().setStyle(ButtonStyle.Primary).setSKUId('123456789012345678');
111+
button.toJSON();
112+
}).toThrowError();
113+
114+
expect(() => {
115+
const button = buttonComponent()
116+
.setStyle(ButtonStyle.Secondary)
117+
.setLabel('button')
118+
.setSKUId('123456789012345678');
119+
120+
button.toJSON();
121+
}).toThrowError();
122+
123+
expect(() => {
124+
const button = buttonComponent()
125+
.setStyle(ButtonStyle.Success)
126+
.setEmoji({ name: '😇' })
127+
.setSKUId('123456789012345678');
128+
129+
button.toJSON();
130+
}).toThrowError();
131+
132+
expect(() => {
133+
const button = buttonComponent()
134+
.setStyle(ButtonStyle.Danger)
135+
.setCustomId('test')
136+
.setSKUId('123456789012345678');
137+
138+
button.toJSON();
139+
}).toThrowError();
140+
141+
expect(() => {
142+
const button = buttonComponent()
143+
.setStyle(ButtonStyle.Link)
144+
.setURL('https://google.com')
145+
.setSKUId('123456789012345678');
146+
147+
button.toJSON();
148+
}).toThrowError();
149+
104150
// @ts-expect-error: Invalid style
105151
expect(() => buttonComponent().setStyle(24)).toThrowError();
106152
expect(() => buttonComponent().setLabel(longStr)).toThrowError();

‎packages/builders/src/components/Assertions.ts

+26-11
Original file line numberDiff line numberDiff line change
@@ -81,21 +81,36 @@ export function validateRequiredButtonParameters(
8181
label?: string,
8282
emoji?: APIMessageComponentEmoji,
8383
customId?: string,
84+
skuId?: string,
8485
url?: string,
8586
) {
86-
if (url && customId) {
87-
throw new RangeError('URL and custom id are mutually exclusive');
88-
}
87+
if (style === ButtonStyle.Premium) {
88+
if (!skuId) {
89+
throw new RangeError('Premium buttons must have an SKU id.');
90+
}
8991

90-
if (!label && !emoji) {
91-
throw new RangeError('Buttons must have a label and/or an emoji');
92-
}
92+
if (customId || label || url || emoji) {
93+
throw new RangeError('Premium buttons cannot have a custom id, label, URL, or emoji.');
94+
}
95+
} else {
96+
if (skuId) {
97+
throw new RangeError('Non-premium buttons must not have an SKU id.');
98+
}
99+
100+
if (url && customId) {
101+
throw new RangeError('URL and custom id are mutually exclusive.');
102+
}
103+
104+
if (!label && !emoji) {
105+
throw new RangeError('Non-premium buttons must have a label and/or an emoji.');
106+
}
93107

94-
if (style === ButtonStyle.Link) {
95-
if (!url) {
96-
throw new RangeError('Link buttons must have a url');
108+
if (style === ButtonStyle.Link) {
109+
if (!url) {
110+
throw new RangeError('Link buttons must have a URL.');
111+
}
112+
} else if (url) {
113+
throw new RangeError('Non-premium and non-link buttons cannot have a URL.');
97114
}
98-
} else if (url) {
99-
throw new RangeError('Non-link buttons cannot have a url');
100115
}
101116
}

‎packages/builders/src/components/button/Button.ts

+13
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type APIButtonComponentWithURL,
77
type APIMessageComponentEmoji,
88
type ButtonStyle,
9+
type Snowflake,
910
} from 'discord-api-types/v10';
1011
import {
1112
buttonLabelValidator,
@@ -89,6 +90,17 @@ export class ButtonBuilder extends ComponentBuilder<APIButtonComponent> {
8990
return this;
9091
}
9192

93+
/**
94+
* Sets the SKU id that represents a purchasable SKU for this button.
95+
*
96+
* @remarks Only available when using premium-style buttons.
97+
* @param skuId - The SKU id to use
98+
*/
99+
public setSKUId(skuId: Snowflake) {
100+
(this.data as APIButtonComponentWithSKUId).sku_id = skuId;
101+
return this;
102+
}
103+
92104
/**
93105
* Sets the emoji to display on this button.
94106
*
@@ -128,6 +140,7 @@ export class ButtonBuilder extends ComponentBuilder<APIButtonComponent> {
128140
(this.data as Exclude<APIButtonComponent, APIButtonComponentWithSKUId>).label,
129141
(this.data as Exclude<APIButtonComponent, APIButtonComponentWithSKUId>).emoji,
130142
(this.data as APIButtonComponentWithCustomId).custom_id,
143+
(this.data as APIButtonComponentWithSKUId).sku_id,
131144
(this.data as APIButtonComponentWithURL).url,
132145
);
133146

‎packages/core/src/api/interactions.ts

+1
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ export class InteractionsAPI {
258258
* @param interactionId - The id of the interaction
259259
* @param interactionToken - The token of the interaction
260260
* @param options - The options for sending the premium required response
261+
* @deprecated Sending a premium-style button is the new Discord behaviour.
261262
*/
262263
public async sendPremiumRequired(
263264
interactionId: Snowflake,

‎packages/discord.js/src/structures/interfaces/InteractionResponses.js

+8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use strict';
22

3+
const { deprecate } = require('node:util');
34
const { isJSONEncodable } = require('@discordjs/util');
45
const { InteractionResponseType, MessageFlags, Routes, InteractionType } = require('discord-api-types/v10');
56
const { DiscordjsError, ErrorCodes } = require('../../errors');
@@ -266,6 +267,7 @@ class InteractionResponses {
266267
/**
267268
* Responds to the interaction with an upgrade button.
268269
* <info>Only available for applications with monetization enabled.</info>
270+
* @deprecated Sending a premium-style button is the new Discord behaviour.
269271
* @returns {Promise<void>}
270272
*/
271273
async sendPremiumRequired() {
@@ -337,4 +339,10 @@ class InteractionResponses {
337339
}
338340
}
339341

342+
InteractionResponses.prototype.sendPremiumRequired = deprecate(
343+
InteractionResponses.prototype.sendPremiumRequired,
344+
// eslint-disable-next-line max-len
345+
'InteractionResponses#sendPremiumRequired() is deprecated. Sending a premium-style button is the new Discord behaviour.',
346+
);
347+
340348
module.exports = InteractionResponses;

‎packages/discord.js/typings/index.d.ts

+3
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,7 @@ export abstract class CommandInteraction<Cached extends CacheType = CacheType> e
604604
| ModalComponentData
605605
| APIModalInteractionResponseCallbackData,
606606
): Promise<void>;
607+
/** @deprecated Sending a premium-style button is the new Discord behaviour. */
607608
public sendPremiumRequired(): Promise<void>;
608609
public awaitModalSubmit(
609610
options: AwaitModalSubmitOptions<ModalSubmitInteraction>,
@@ -2261,6 +2262,7 @@ export class MessageComponentInteraction<Cached extends CacheType = CacheType> e
22612262
| ModalComponentData
22622263
| APIModalInteractionResponseCallbackData,
22632264
): Promise<void>;
2265+
/** @deprecated Sending a premium-style button is the new Discord behaviour. */
22642266
public sendPremiumRequired(): Promise<void>;
22652267
public awaitModalSubmit(
22662268
options: AwaitModalSubmitOptions<ModalSubmitInteraction>,
@@ -2460,6 +2462,7 @@ export class ModalSubmitInteraction<Cached extends CacheType = CacheType> extend
24602462
options: InteractionDeferUpdateOptions & { fetchReply: true },
24612463
): Promise<Message<BooleanCache<Cached>>>;
24622464
public deferUpdate(options?: InteractionDeferUpdateOptions): Promise<InteractionResponse<BooleanCache<Cached>>>;
2465+
/** @deprecated Sending a premium-style button is the new Discord behaviour. */
24632466
public sendPremiumRequired(): Promise<void>;
24642467
public inGuild(): this is ModalSubmitInteraction<'raw' | 'cached'>;
24652468
public inCachedGuild(): this is ModalSubmitInteraction<'cached'>;

‎packages/discord.js/typings/index.test-d.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ import {
206206
MentionableSelectMenuComponent,
207207
Poll,
208208
} from '.';
209-
import { expectAssignable, expectNotAssignable, expectNotType, expectType } from 'tsd';
209+
import { expectAssignable, expectDeprecated, expectNotAssignable, expectNotType, expectType } from 'tsd';
210210
import type { ContextMenuCommandBuilder, SlashCommandBuilder } from '@discordjs/builders';
211211
import { ReadonlyCollection } from '@discordjs/collection';
212212

@@ -1763,6 +1763,7 @@ client.on('interactionCreate', async interaction => {
17631763
expectType<AnySelectMenuInteraction | ButtonInteraction>(interaction);
17641764
expectType<MessageActionRowComponent | APIButtonComponent | APISelectMenuComponent>(interaction.component);
17651765
expectType<Message>(interaction.message);
1766+
expectDeprecated(interaction.sendPremiumRequired());
17661767
if (interaction.inCachedGuild()) {
17671768
expectAssignable<MessageComponentInteraction>(interaction);
17681769
expectType<MessageActionRowComponent>(interaction.component);
@@ -1950,6 +1951,7 @@ client.on('interactionCreate', async interaction => {
19501951
interaction.type === InteractionType.ApplicationCommand &&
19511952
interaction.commandType === ApplicationCommandType.ChatInput
19521953
) {
1954+
expectDeprecated(interaction.sendPremiumRequired());
19531955
if (interaction.inRawGuild()) {
19541956
expectNotAssignable<Interaction<'cached'>>(interaction);
19551957
expectAssignable<ChatInputCommandInteraction>(interaction);
@@ -2073,6 +2075,10 @@ client.on('interactionCreate', async interaction => {
20732075
expectType<Promise<Message>>(interaction.followUp({ content: 'a' }));
20742076
}
20752077
}
2078+
2079+
if (interaction.isModalSubmit()) {
2080+
expectDeprecated(interaction.sendPremiumRequired());
2081+
}
20762082
});
20772083

20782084
declare const shard: Shard;

‎packages/formatters/__tests__/formatters.test.ts

+15
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { URL } from 'node:url';
33
import { describe, test, expect, vitest } from 'vitest';
44
import {
5+
applicationDirectory,
56
chatInputApplicationCommandMention,
67
blockQuote,
78
bold,
@@ -313,6 +314,20 @@ describe('Message formatters', () => {
313314
});
314315
});
315316

317+
describe('applicationDirectory', () => {
318+
test('GIVEN application id THEN returns application directory store', () => {
319+
expect(applicationDirectory('123456789012345678')).toEqual(
320+
'https://discord.com/application-directory/123456789012345678/store',
321+
);
322+
});
323+
324+
test('GIVEN application id AND SKU id THEN returns SKU within the application directory store', () => {
325+
expect(applicationDirectory('123456789012345678', '123456789012345678')).toEqual(
326+
'https://discord.com/application-directory/123456789012345678/store/123456789012345678',
327+
);
328+
});
329+
});
330+
316331
describe('Faces', () => {
317332
test('GIVEN Faces.Shrug THEN returns "¯\\_(ツ)_/¯"', () => {
318333
expect<'¯\\_(ツ)_/¯'>(Faces.Shrug).toEqual('¯\\_(ツ)_/¯');

‎packages/formatters/src/formatters.ts

+33
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,39 @@ export function time(timeOrSeconds?: Date | number, style?: TimestampStylesStrin
615615
return typeof style === 'string' ? `<t:${timeOrSeconds}:${style}>` : `<t:${timeOrSeconds}>`;
616616
}
617617

618+
/**
619+
* Formats an application directory link.
620+
*
621+
* @typeParam ApplicationId - This is inferred by the supplied application id
622+
* @param applicationId - The application id
623+
*/
624+
export function applicationDirectory<ApplicationId extends Snowflake>(
625+
applicationId: ApplicationId,
626+
): `https://discord.com/application-directory/${ApplicationId}/store`;
627+
628+
/**
629+
* Formats an application directory SKU link.
630+
*
631+
* @typeParam ApplicationId - This is inferred by the supplied application id
632+
* @typeParam SKUId - This is inferred by the supplied SKU id
633+
* @param applicationId - The application id
634+
* @param skuId - The SKU id
635+
*/
636+
export function applicationDirectory<ApplicationId extends Snowflake, SKUId extends Snowflake>(
637+
applicationId: ApplicationId,
638+
skuId: SKUId,
639+
): `https://discord.com/application-directory/${ApplicationId}/store/${SKUId}`;
640+
641+
export function applicationDirectory<ApplicationId extends Snowflake, SKUId extends Snowflake>(
642+
applicationId: ApplicationId,
643+
skuId?: SKUId,
644+
):
645+
| `https://discord.com/application-directory/${ApplicationId}/store/${SKUId}`
646+
| `https://discord.com/application-directory/${ApplicationId}/store` {
647+
const url = `https://discord.com/application-directory/${applicationId}/store` as const;
648+
return skuId ? `${url}/${skuId}` : url;
649+
}
650+
618651
/**
619652
* The {@link https://discord.com/developers/docs/reference#message-formatting-timestamp-styles | message formatting timestamp styles}
620653
* supported by Discord.

0 commit comments

Comments
 (0)
Please sign in to comment.