diff --git a/packages/builders/__tests__/interactions/ContextMenuCommands.test.ts b/packages/builders/__tests__/interactions/ContextMenuCommands.test.ts index 644df5448..16047ce7e 100644 --- a/packages/builders/__tests__/interactions/ContextMenuCommands.test.ts +++ b/packages/builders/__tests__/interactions/ContextMenuCommands.test.ts @@ -85,5 +85,36 @@ describe('Context Menu Commands', () => { expect(() => getBuilder().setName('foo').setDefaultPermission(false)).not.toThrowError(); }); }); + + describe('Context menu command localizations', () => { + const expectedSingleLocale = { 'en-US': 'foobar' }; + const expectedMultipleLocales = { + ...expectedSingleLocale, + bg: 'test', + }; + + test('GIVEN valid name localizations THEN does not throw error', () => { + expect(() => getBuilder().setNameLocalization('en-US', 'foobar')).not.toThrowError(); + expect(() => getBuilder().setNameLocalizations({ 'en-US': 'foobar' })).not.toThrowError(); + }); + + test('GIVEN invalid name localizations THEN does throw error', () => { + // @ts-expect-error + expect(() => getBuilder().setNameLocalization('en-U', 'foobar')).toThrowError(); + // @ts-expect-error + expect(() => getBuilder().setNameLocalizations({ 'en-U': 'foobar' })).toThrowError(); + }); + + test('GIVEN valid name localizations THEN valid data is stored', () => { + expect(getBuilder().setNameLocalization('en-US', 'foobar').name_localizations).toEqual(expectedSingleLocale); + expect(getBuilder().setNameLocalizations({ 'en-US': 'foobar', bg: 'test' }).name_localizations).toEqual( + expectedMultipleLocales, + ); + expect(getBuilder().setNameLocalizations(null).name_localizations).toBeNull(); + expect(getBuilder().setNameLocalization('en-US', null).name_localizations).toEqual({ + 'en-US': null, + }); + }); + }); }); }); diff --git a/packages/builders/__tests__/interactions/SlashCommands/SlashCommands.test.ts b/packages/builders/__tests__/interactions/SlashCommands/SlashCommands.test.ts index d08da7228..380be58ed 100644 --- a/packages/builders/__tests__/interactions/SlashCommands/SlashCommands.test.ts +++ b/packages/builders/__tests__/interactions/SlashCommands/SlashCommands.test.ts @@ -433,7 +433,7 @@ describe('Slash Commands', () => { expect(() => getBuilder().setNameLocalizations({ 'en-US': 'foobar' })).not.toThrowError(); }); - test('GIVEN valid name localizations THEN does not throw error', () => { + test('GIVEN invalid name localizations THEN does throw error', () => { // @ts-expect-error expect(() => getBuilder().setNameLocalization('en-U', 'foobar')).toThrowError(); // @ts-expect-error @@ -456,7 +456,7 @@ describe('Slash Commands', () => { expect(() => getBuilder().setDescriptionLocalizations({ 'en-US': 'foobar' })).not.toThrowError(); }); - test('GIVEN valid description localizations THEN does not throw error', () => { + test('GIVEN invalid description localizations THEN does throw error', () => { // @ts-expect-error expect(() => getBuilder().setDescriptionLocalization('en-U', 'foobar')).toThrowError(); // @ts-expect-error diff --git a/packages/builders/src/interactions/contextMenuCommands/ContextMenuCommandBuilder.ts b/packages/builders/src/interactions/contextMenuCommands/ContextMenuCommandBuilder.ts index 8481626db..0567c7a69 100644 --- a/packages/builders/src/interactions/contextMenuCommands/ContextMenuCommandBuilder.ts +++ b/packages/builders/src/interactions/contextMenuCommands/ContextMenuCommandBuilder.ts @@ -1,5 +1,11 @@ -import type { ApplicationCommandType, RESTPostAPIApplicationCommandsJSONBody } from 'discord-api-types/v10'; +import type { + ApplicationCommandType, + LocaleString, + LocalizationMap, + RESTPostAPIApplicationCommandsJSONBody, +} from 'discord-api-types/v10'; import { validateRequiredParameters, validateName, validateType, validateDefaultPermission } from './Assertions'; +import { validateLocale, validateLocalizationMap } from '../slashCommands/Assertions'; export class ContextMenuCommandBuilder { /** @@ -7,6 +13,11 @@ export class ContextMenuCommandBuilder { */ public readonly name: string = undefined!; + /** + * The localized names for this command + */ + public readonly name_localizations?: LocalizationMap; + /** * The type of this context menu command */ @@ -65,6 +76,49 @@ export class ContextMenuCommandBuilder { return this; } + /** + * Sets a name localization + * + * @param locale The locale to set a description for + * @param localizedName The localized description for the given locale + */ + public setNameLocalization(locale: LocaleString, localizedName: string | null) { + if (!this.name_localizations) { + Reflect.set(this, 'name_localizations', {}); + } + + const parsedLocale = validateLocale(locale); + + if (localizedName === null) { + this.name_localizations![parsedLocale] = null; + return this; + } + + validateName(localizedName); + + this.name_localizations![parsedLocale] = localizedName; + return this; + } + + /** + * Sets the name localizations + * + * @param localizedNames The dictionary of localized descriptions to set + */ + public setNameLocalizations(localizedNames: LocalizationMap | null) { + if (localizedNames === null) { + Reflect.set(this, 'name_localizations', null); + return this; + } + + Reflect.set(this, 'name_localizations', {}); + + Object.entries(localizedNames).forEach((args) => + this.setNameLocalization(...(args as [LocaleString, string | null])), + ); + return this; + } + /** * Returns the final data that should be sent to Discord. * @@ -72,8 +126,12 @@ export class ContextMenuCommandBuilder { */ public toJSON(): RESTPostAPIApplicationCommandsJSONBody { validateRequiredParameters(this.name, this.type); + + validateLocalizationMap(this.name_localizations); + return { name: this.name, + name_localizations: this.name_localizations, type: this.type, default_permission: this.defaultPermission, }; diff --git a/packages/builders/src/interactions/slashCommands/Assertions.ts b/packages/builders/src/interactions/slashCommands/Assertions.ts index 2ea9e5c8e..e9d82d697 100644 --- a/packages/builders/src/interactions/slashCommands/Assertions.ts +++ b/packages/builders/src/interactions/slashCommands/Assertions.ts @@ -1,6 +1,6 @@ import { s } from '@sapphire/shapeshift'; import is from '@sindresorhus/is'; -import { type APIApplicationCommandOptionChoice, Locale } from 'discord-api-types/v10'; +import { type APIApplicationCommandOptionChoice, Locale, LocalizationMap } from 'discord-api-types/v10'; import type { ToAPIApplicationCommandOptions } from './SlashCommandBuilder'; import type { SlashCommandSubcommandBuilder, SlashCommandSubcommandGroupBuilder } from './SlashCommandSubcommands'; import type { ApplicationCommandOptionBase } from './mixins/ApplicationCommandOptionBase'; @@ -87,3 +87,11 @@ export function assertReturnOfBuilder< throw new TypeError(`Expected to receive a ${instanceName} builder, got ${fullResultName} instead.`); } } + +export const localizationMapPredicate = s.object( + Object.fromEntries(Object.values(Locale).map((locale) => [locale, s.string.nullish])), +).strict.nullish; + +export function validateLocalizationMap(value: unknown): asserts value is LocalizationMap { + localizationMapPredicate.parse(value); +} diff --git a/packages/builders/src/interactions/slashCommands/SlashCommandBuilder.ts b/packages/builders/src/interactions/slashCommands/SlashCommandBuilder.ts index 26d11d1a5..163ae1319 100644 --- a/packages/builders/src/interactions/slashCommands/SlashCommandBuilder.ts +++ b/packages/builders/src/interactions/slashCommands/SlashCommandBuilder.ts @@ -7,6 +7,7 @@ import { mix } from 'ts-mixer'; import { assertReturnOfBuilder, validateDefaultPermission, + validateLocalizationMap, validateMaxOptionsLength, validateRequiredParameters, } from './Assertions'; @@ -56,6 +57,9 @@ export class SlashCommandBuilder { public toJSON(): RESTPostAPIApplicationCommandsJSONBody { validateRequiredParameters(this.name, this.description, this.options); + validateLocalizationMap(this.name_localizations); + validateLocalizationMap(this.description_localizations); + return { name: this.name, name_localizations: this.name_localizations, diff --git a/packages/builders/src/interactions/slashCommands/SlashCommandSubcommands.ts b/packages/builders/src/interactions/slashCommands/SlashCommandSubcommands.ts index 3a55cd248..b7cd1b269 100644 --- a/packages/builders/src/interactions/slashCommands/SlashCommandSubcommands.ts +++ b/packages/builders/src/interactions/slashCommands/SlashCommandSubcommands.ts @@ -66,7 +66,9 @@ export class SlashCommandSubcommandGroupBuilder implements ToAPIApplicationComma return { type: ApplicationCommandOptionType.SubcommandGroup, name: this.name, + name_localizations: this.name_localizations, description: this.description, + description_localizations: this.description_localizations, options: this.options.map((option) => option.toJSON()), }; } @@ -102,7 +104,9 @@ export class SlashCommandSubcommandBuilder implements ToAPIApplicationCommandOpt return { type: ApplicationCommandOptionType.Subcommand, name: this.name, + name_localizations: this.name_localizations, description: this.description, + description_localizations: this.description_localizations, options: this.options.map((option) => option.toJSON()), }; } diff --git a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionBase.ts b/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionBase.ts index 181c4b074..ecefc88ca 100644 --- a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionBase.ts +++ b/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionBase.ts @@ -1,6 +1,6 @@ import type { APIApplicationCommandBasicOption, ApplicationCommandOptionType } from 'discord-api-types/v10'; import { SharedNameAndDescription } from './NameAndDescription'; -import { validateRequiredParameters, validateRequired } from '../Assertions'; +import { validateRequiredParameters, validateRequired, validateLocalizationMap } from '../Assertions'; export abstract class ApplicationCommandOptionBase extends SharedNameAndDescription { public abstract readonly type: ApplicationCommandOptionType; @@ -26,6 +26,10 @@ export abstract class ApplicationCommandOptionBase extends SharedNameAndDescript protected runRequiredValidations() { validateRequiredParameters(this.name, this.description, []); + // Validate localizations + validateLocalizationMap(this.name_localizations); + validateLocalizationMap(this.description_localizations); + // Assert that you actually passed a boolean validateRequired(this.required); } diff --git a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithChoicesAndAutocompleteMixin.ts b/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithChoicesAndAutocompleteMixin.ts index 2e94b7f8b..496500e4e 100644 --- a/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithChoicesAndAutocompleteMixin.ts +++ b/packages/builders/src/interactions/slashCommands/mixins/ApplicationCommandOptionWithChoicesAndAutocompleteMixin.ts @@ -1,10 +1,14 @@ import { s } from '@sapphire/shapeshift'; import { APIApplicationCommandOptionChoice, ApplicationCommandOptionType } from 'discord-api-types/v10'; -import { validateChoicesLength } from '../Assertions'; +import { localizationMapPredicate, validateChoicesLength } from '../Assertions'; const stringPredicate = s.string.lengthGe(1).lengthLe(100); const numberPredicate = s.number.gt(-Infinity).lt(Infinity); -const choicesPredicate = s.object({ name: stringPredicate, value: s.union(stringPredicate, numberPredicate) }).array; +const choicesPredicate = s.object({ + name: stringPredicate, + name_localizations: localizationMapPredicate, + value: s.union(stringPredicate, numberPredicate), +}).array; const booleanPredicate = s.boolean; export class ApplicationCommandOptionWithChoicesAndAutocompleteMixin { @@ -32,7 +36,7 @@ export class ApplicationCommandOptionWithChoicesAndAutocompleteMixin