diff --git a/package-lock.json b/package-lock.json index d6274da..195a049 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "slshx", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "slshx", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { - "discord-api-types": "^0.27.0" + "discord-api-types": "^0.37.28" }, "devDependencies": { "@ava/typescript": "^3.0.1", @@ -1543,12 +1543,9 @@ } }, "node_modules/discord-api-types": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.27.0.tgz", - "integrity": "sha512-l7AnQKffohJTembJRN4Bz54R/esSn+WmulWE7bDIHVcuCLdsUhlvt+4tns8ox+wsEPVlT+gEEko2BF5AL8/zjw==", - "engines": { - "node": ">=12" - } + "version": "0.37.28", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.28.tgz", + "integrity": "sha512-K0fw7m7km9th3dCQ2AR90q/FwX3uAj+OLc+Zuo39VY9vCn0Ux/iObM4y1zJYIH3vTc+QlrksVErUvyeONjOKMQ==" }, "node_modules/doctrine": { "version": "3.0.0", @@ -6030,9 +6027,9 @@ } }, "discord-api-types": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.27.0.tgz", - "integrity": "sha512-l7AnQKffohJTembJRN4Bz54R/esSn+WmulWE7bDIHVcuCLdsUhlvt+4tns8ox+wsEPVlT+gEEko2BF5AL8/zjw==" + "version": "0.37.28", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.28.tgz", + "integrity": "sha512-K0fw7m7km9th3dCQ2AR90q/FwX3uAj+OLc+Zuo39VY9vCn0Ux/iObM4y1zJYIH3vTc+QlrksVErUvyeONjOKMQ==" }, "doctrine": { "version": "3.0.0", diff --git a/package.json b/package.json index 71c9d98..f7cc045 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,6 @@ "typescript": "^4.5.4" }, "dependencies": { - "discord-api-types": "^0.27.0" + "discord-api-types": "^0.37.28" } } diff --git a/src/commands/hooks.ts b/src/commands/hooks.ts index 1ee3b1d..a01ef64 100644 --- a/src/commands/hooks.ts +++ b/src/commands/hooks.ts @@ -8,6 +8,7 @@ import type { APIRole, APIUser, ChannelType, + LocalizationMap, } from "discord-api-types/v9"; import { ApplicationCommandOptionType } from "../api"; import { Awaitable, ValueOf } from "../helpers"; @@ -21,6 +22,41 @@ export function useDescription(description: string): void { if (STATE.recordingOptions) STATE.recordingDescription = description; } +export function useLocalizations({ + name, + description, +}: { + name?: LocalizationMap; + description?: LocalizationMap; +}): void { + if (!STATE.commandId) { + throw new Error(`Hooks must be called inside a command`); + } + if (STATE.recordingOptions) { + if (name) STATE.recordingNameLocalizations = name; + if (description) STATE.recordingDescriptionLocalizations = description; + } +} + +export function useNameLocalizations(localizations: LocalizationMap): void { + if (!STATE.commandId) { + throw new Error(`Hooks must be called inside a command`); + } + if (STATE.recordingOptions) { + STATE.recordingNameLocalizations = localizations; + } +} + +export function useDescriptionLocalizations( + localizations: LocalizationMap +): void { + if (!STATE.commandId) { + throw new Error(`Hooks must be called inside a command`); + } + if (STATE.recordingOptions) + STATE.recordingDescriptionLocalizations = localizations; +} + export function useDefaultPermission(permission: boolean): void { if (!STATE.commandId) { throw new Error(`Hooks must be called inside a command`); @@ -28,6 +64,13 @@ export function useDefaultPermission(permission: boolean): void { if (STATE.recordingOptions) STATE.recordingDefaultPermission = permission; } +export function useDMPermission(permission: boolean): void { + if (!STATE.commandId) { + throw new Error(`Hooks must be called inside a command`); + } + if (STATE.recordingOptions) STATE.recordingDMPermission = permission; +} + // ======================================================================================================== // | Message Component & Modal Hooks: | // | https://discord.com/developers/docs/interactions/message-components#component-object-component-types | @@ -100,24 +143,30 @@ export type AutocompleteHandler = ( ctx: ExecutionContext ) => Awaitable[]>; -export interface OptionalOption { +export interface LocalizationOption { + localizations?: { + name?: LocalizationMap; + description?: LocalizationMap; + }; +} +export interface OptionalOption extends LocalizationOption { required?: false; autocomplete?: AutocompleteHandler; } -export interface RequiredOption { +export interface RequiredOption extends LocalizationOption { required: true; autocomplete?: AutocompleteHandler; } export interface OptionalChoicesOption< Choices extends ReadonlyArray> -> { +> extends LocalizationOption { required?: false; choices: Choices; } export interface RequiredChoicesOption< Choices extends ReadonlyArray> -> { +> extends LocalizationOption { required: true; choices: Choices; } @@ -126,6 +175,10 @@ export interface NumericOption { min?: number; max?: number; } +export interface StringOption { + minLength?: number; + maxLength?: number; +} export interface ChannelOption { types?: ChannelType[]; } @@ -135,7 +188,9 @@ type CombinedOption = { autocomplete?: AutocompleteHandler; choices?: ReadonlyArray>; } & NumericOption & - ChannelOption; + ChannelOption & + StringOption & + LocalizationOption; function useOption( type: ValueOf, @@ -166,13 +221,17 @@ function useOption( STATE.recordingOptions.push({ type: type as number, name, + name_localizations: options?.localizations?.name, description, + description_localizations: options?.localizations?.description, required: options?.required, autocomplete: options?.autocomplete && true, choices: normaliseChoices(options?.choices as any) as any, channel_types: options?.types as any, min_value: options?.min, max_value: options?.max, + min_length: options?.minLength, + max_length: options?.maxLength, }); } return def; @@ -181,27 +240,27 @@ function useOption( export function useString( name: string, description: string, - options?: OptionalOption + options?: OptionalOption & StringOption ): string | null; export function useString( name: string, description: string, - options: RequiredOption + options: RequiredOption & StringOption ): string; export function useString>>( name: string, description: string, - options: OptionalChoicesOption + options: OptionalChoicesOption & StringOption ): ChoiceValue | null; export function useString>>( name: string, description: string, - options: RequiredChoicesOption + options: RequiredChoicesOption & StringOption ): ChoiceValue; export function useString( name: string, description: string, - options?: CombinedOption + options?: CombinedOption & StringOption ): string | null { return useOption( ApplicationCommandOptionType.STRING, diff --git a/src/commands/recorders.ts b/src/commands/recorders.ts index 6a3ba2a..b8ab19c 100644 --- a/src/commands/recorders.ts +++ b/src/commands/recorders.ts @@ -33,13 +33,22 @@ function recordCommand( requireDescription = true ): Pick< APIApplicationCommand, - "name" | "description" | "options" | "default_permission" + | "name" + | "name_localizations" + | "description" + | "options" + | "default_permission" + | "description_localizations" + | "dm_permission" > { STATE.commandId = commandId; STATE.recordingOptions = []; STATE.recordingDescription = ""; + STATE.recordingDescriptionLocalizations = undefined; + STATE.recordingNameLocalizations = undefined; STATE.recordingDefaultPermission = undefined; STATE.componentHandlerCount = 0; + STATE.recordingDMPermission = undefined; try { // Run hooks and record options command(); @@ -53,11 +62,14 @@ function recordCommand( return { name, + name_localizations: STATE.recordingNameLocalizations, description: STATE.recordingDescription, options: STATE.recordingOptions.length ? STATE.recordingOptions : undefined, default_permission: STATE.recordingDefaultPermission, + description_localizations: STATE.recordingDescriptionLocalizations, + dm_permission: STATE.recordingDMPermission, }; } finally { STATE.commandId = undefined; diff --git a/src/commands/state.ts b/src/commands/state.ts index 776e538..bb8abef 100644 --- a/src/commands/state.ts +++ b/src/commands/state.ts @@ -1,7 +1,8 @@ import type { APIApplicationCommandInteractionDataBasicOption, APIApplicationCommandOption, - APIChatInputApplicationCommandInteractionDataResolved, + APIInteractionDataResolved, + LocalizationMap, } from "discord-api-types/v9"; import { AutocompleteHandler } from "./hooks"; import { ComponentHandler, ModalHandler } from "./types"; @@ -13,14 +14,18 @@ interface State { // Recorded command for deployment recordingOptions?: APIApplicationCommandOption[]; recordingDescription: string; + recordingDescriptionLocalizations?: LocalizationMap; + recordingNameLocalizations?: LocalizationMap; + recordingDefaultPermission?: boolean; + recordingDMPermission?: boolean; // Incoming interaction data interactionOptions?: Map< string, APIApplicationCommandInteractionDataBasicOption >; // name -> value - interactionResolved?: APIChatInputApplicationCommandInteractionDataResolved; + interactionResolved?: APIInteractionDataResolved; interactionComponentData?: Map; // custom_id -> data // Component interaction and modal submit handlers diff --git a/src/jsx/Message.ts b/src/jsx/Message.ts index d48bf10..b2f013b 100644 --- a/src/jsx/Message.ts +++ b/src/jsx/Message.ts @@ -3,7 +3,7 @@ import type { APIButtonComponent, APIEmbed, APIInteractionResponseCallbackData, - APIMessageComponent, + APIMessageActionRowComponent, APISelectMenuComponent, Snowflake, } from "discord-api-types/v9"; @@ -30,7 +30,9 @@ export interface MessageProps { children?: ( | Child | (APIEmbed & { [$embed]: true }) - | (APIActionRowComponent & { [$actionRow]: true }) + | (APIActionRowComponent & { + [$actionRow]: true; + }) | (APIButtonComponent & { [$actionRowChild]: true }) | (APISelectMenuComponent & { [$actionRowChild]: true }) )[]; @@ -44,7 +46,7 @@ export function Message( // Sort children into correct slots let content = undefined; const embeds: APIEmbed[] = []; - const components: APIActionRowComponent[] = []; + const components: APIActionRowComponent[] = []; for (const child of props.children?.flat(Infinity) ?? []) { if (isEmptyChild(child)) continue; if ((child as any)[$embed]) { diff --git a/src/jsx/Modal.ts b/src/jsx/Modal.ts index 7b1de25..30957fd 100644 --- a/src/jsx/Modal.ts +++ b/src/jsx/Modal.ts @@ -1,6 +1,6 @@ import type { APIActionRowComponent, - APIModalComponent, + APIModalActionRowComponent, APITextInputComponent, } from "discord-api-types/v9"; import { ComponentType } from "../api"; @@ -12,13 +12,15 @@ export interface ModalProps { id: string; title: string; children?: ( - | (APIActionRowComponent & { [$actionRow]: true }) + | (APIActionRowComponent & { + [$actionRow]: true; + }) | (APITextInputComponent & { [$actionRowChild]: true }) )[]; } export function Modal(props: ModalProps): ModalResponse { - const components: APIActionRowComponent[] = []; + const components: APIActionRowComponent[] = []; for (const child of props.children?.flat(Infinity) ?? []) { if (isEmptyChild(child)) continue; if ((child as any)[$actionRow]) { diff --git a/test/integration/fixture.ts b/test/integration/fixture.ts index 2723103..648a536 100644 --- a/test/integration/fixture.ts +++ b/test/integration/fixture.ts @@ -14,10 +14,12 @@ import { useBoolean, useButton, useChannel, + useDMPermission, useDefaultPermission, useDescription, useInput, useInteger, + useLocalizations, useMentionable, useModal, useNumber, @@ -146,6 +148,56 @@ function choices(): CommandHandler { }; } +function translate(): CommandHandler { + useDescription("Translates a string"); + useLocalizations({ + description: { + nl: "Vertaalt een string", + fr: "Traduit une chaîne", + }, + name: { + nl: "vertalen", + fr: "traduire", + }, + }); + const text = useString("string", "String to translate", { + required: true, + localizations: { + description: { + nl: "Tekst om te vertalen", + fr: "Chaîne à traduire", + }, + name: { + nl: "tekst", + fr: "chaîne", + }, + }, + }); + return () => { + return { content: `Translated: ${text}` }; + }; +} + +function limit(): CommandHandler { + useDescription("Limit the amount of characters in a string"); + const text = useString("string", "String to limit", { + required: true, + minLength: 3, + maxLength: 5, + }); + return () => { + return { content: `Limited: ${text}` }; + }; +} + +function nodm(): CommandHandler { + useDescription("Can't be used in DMs"); + useDMPermission(false); + return () => { + return { content: "Success!" }; + }; +} + function files(): CommandHandler { useDescription("Uploads some files"); // Implicitly check returning async function @@ -427,6 +479,9 @@ const handler = createHandler({ all, choices, files, + translate, + limit, + nodm, autocomplete, buttons, modals, diff --git a/test/integration/helpers.ts b/test/integration/helpers.ts index 7bd21bf..47c219e 100644 --- a/test/integration/helpers.ts +++ b/test/integration/helpers.ts @@ -284,6 +284,53 @@ export const EXPECTED_COMMANDS: RESTPostAPIApplicationCommandsJSONBody[] = [ ], }, { name: "files", description: "Uploads some files" }, + { + name: "translate", + description: "Translates a string", + name_localizations: { + nl: "vertalen", + fr: "traduire", + }, + description_localizations: { + nl: "Vertaalt een string", + fr: "Traduit une chaîne", + }, + options: [ + { + type: 3, + name: "string", + description: "String to translate", + required: true, + name_localizations: { + nl: "tekst", + fr: "chaîne", + }, + description_localizations: { + nl: "Tekst om te vertalen", + fr: "Chaîne à traduire", + }, + }, + ], + }, + { + name: "limit", + description: "Limit the amount of characters in a string", + options: [ + { + type: 3, + name: "string", + description: "String to limit", + required: true, + min_length: 3, + max_length: 5, + }, + ], + }, + { + name: "nodm", + description: "Can't be used in DMs", + dm_permission: false, + }, { name: "autocomplete", description: "Autocompletes an option",