diff --git a/packages/builders/__tests__/components/fileUpload.test.ts b/packages/builders/__tests__/components/fileUpload.test.ts index deaee5cc6..7e64b2df5 100644 --- a/packages/builders/__tests__/components/fileUpload.test.ts +++ b/packages/builders/__tests__/components/fileUpload.test.ts @@ -1,4 +1,4 @@ -import type { APIFileUploadComponent } from 'discord-api-types/v10'; +import type { APIFileUploadComponent, FileUploadType } from 'discord-api-types/v10'; import { ComponentType } from 'discord-api-types/v10'; import { describe, test, expect } from 'vitest'; import { FileUploadBuilder } from '../../src/components/fileUpload/FileUpload.js'; @@ -13,6 +13,16 @@ describe('File Upload Components', () => { ).not.toThrowError(); expect(() => new FileUploadBuilder().setCustomId('file_upload').setRequired(false).toJSON()).not.toThrowError(); + expect(() => + new FileUploadBuilder().setCustomId('file_upload').setFileTypes('audio', 'image', 'video', '.pdf').toJSON(), + ).not.toThrowError(); + }); + + test('File types can be added.', () => { + expect( + new FileUploadBuilder().setCustomId('file_upload').addFileTypes('image').addFileTypes(['.pdf']).toJSON() + .file_types, + ).toEqual(['image', '.pdf']); }); test('Invalid builder does throw.', () => { @@ -26,6 +36,29 @@ describe('File Upload Components', () => { ).toThrowError(); expect(() => new FileUploadBuilder().setRequired(false).toJSON()).toThrowError(); + expect(() => + new FileUploadBuilder() + .setCustomId('file_upload') + .setFileTypes(Array.from({ length: 11 }, () => '.txt' as const)) + .toJSON(), + ).toThrowError(); + + for (const invalidFileType of ['document', 'pdf', '.']) { + expect(() => + new FileUploadBuilder() + .setCustomId('file_upload') + .setFileTypes(invalidFileType as FileUploadType) + .toJSON(), + ).toThrowError(); + } + + expect(() => + new FileUploadBuilder({ + type: ComponentType.FileUpload, + custom_id: 'file_upload', + file_types: ['document' as FileUploadType], + }).toJSON(), + ).toThrowError(); }); test('API data equals toJSON().', () => { @@ -34,13 +67,22 @@ describe('File Upload Components', () => { custom_id: 'file_upload', min_values: 4, max_values: 9, + file_types: ['image', '.pdf'], required: false, } satisfies APIFileUploadComponent; expect(new FileUploadBuilder(fileUploadData).toJSON()).toEqual(fileUploadData); expect( - new FileUploadBuilder().setCustomId('file_upload').setMinValues(4).setMaxValues(9).setRequired(false).toJSON(), + new FileUploadBuilder() + .setCustomId('file_upload') + .setMinValues(4) + .setMaxValues(9) + .setFileTypes(fileUploadData.file_types) + .setRequired(false) + .toJSON(), ).toEqual(fileUploadData); + + expect(new FileUploadBuilder(fileUploadData).clearFileTypes().toJSON().file_types).toBeUndefined(); }); }); diff --git a/packages/builders/__tests__/interactions/SlashCommands/Options.test.ts b/packages/builders/__tests__/interactions/SlashCommands/Options.test.ts index 8c985bb11..96b36f8c0 100644 --- a/packages/builders/__tests__/interactions/SlashCommands/Options.test.ts +++ b/packages/builders/__tests__/interactions/SlashCommands/Options.test.ts @@ -10,6 +10,7 @@ import { type APIApplicationCommandRoleOption, type APIApplicationCommandStringOption, type APIApplicationCommandUserOption, + type FileUploadType, } from 'discord-api-types/v10'; import { describe, test, expect } from 'vitest'; import { @@ -212,11 +213,37 @@ describe('Application Command toJSON() results', () => { }); test('GIVEN an attachment option THEN calling toJSON should return a valid JSON', () => { - expect(getAttachmentOption().toJSON()).toEqual({ + expect( + getAttachmentOption().setFileTypes(['image', '.pdf']).toJSON(), + ).toEqual({ name: 'attachment', description: 'attachment', type: ApplicationCommandOptionType.Attachment, required: true, + file_types: ['image', '.pdf'], }); }); + + test('GIVEN attachment option file types THEN they can be added', () => { + expect( + Reflect.get(getAttachmentOption().addFileTypes('image').addFileTypes(['.pdf']).toJSON(), 'file_types'), + ).toEqual(['image', '.pdf']); + }); + + test('GIVEN attachment option file types THEN they can be cleared', () => { + expect( + Reflect.get(getAttachmentOption().setFileTypes('audio', '.ogg').clearFileTypes().toJSON(), 'file_types'), + ).toBeUndefined(); + }); + + test('GIVEN too many attachment option file types THEN the builder should throw', () => { + expect(() => getAttachmentOption().setFileTypes(Array.from({ length: 11 }, () => '.txt' as const))).toThrowError(); + }); + + test.each(['document', 'pdf', '.'])( + 'GIVEN invalid attachment file type %s THEN the builder should throw', + (fileType) => { + expect(() => getAttachmentOption().setFileTypes(fileType as FileUploadType)).toThrowError(); + }, + ); }); diff --git a/packages/builders/src/components/fileUpload/Assertions.ts b/packages/builders/src/components/fileUpload/Assertions.ts index 8f3683d16..b48e44685 100644 --- a/packages/builders/src/components/fileUpload/Assertions.ts +++ b/packages/builders/src/components/fileUpload/Assertions.ts @@ -1,5 +1,6 @@ import { s } from '@sapphire/shapeshift'; import { ComponentType } from 'discord-api-types/v10'; +import { fileUploadTypesValidator } from '../../util/fileUpload.js'; import { customIdValidator, idValidator } from '../Assertions.js'; export const fileUploadPredicate = s.object({ @@ -8,5 +9,6 @@ export const fileUploadPredicate = s.object({ custom_id: customIdValidator, min_values: s.number().greaterThanOrEqual(0).lessThanOrEqual(10).optional(), max_values: s.number().greaterThanOrEqual(1).lessThanOrEqual(10).optional(), + file_types: fileUploadTypesValidator.optional(), required: s.boolean().optional(), }); diff --git a/packages/builders/src/components/fileUpload/FileUpload.ts b/packages/builders/src/components/fileUpload/FileUpload.ts index 0985d2b0e..6306e0056 100644 --- a/packages/builders/src/components/fileUpload/FileUpload.ts +++ b/packages/builders/src/components/fileUpload/FileUpload.ts @@ -1,4 +1,5 @@ -import { type APIFileUploadComponent, ComponentType } from 'discord-api-types/v10'; +import { type APIFileUploadComponent, ComponentType, type FileUploadType } from 'discord-api-types/v10'; +import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; import { ComponentBuilder } from '../Component.js'; import { fileUploadPredicate } from './Assertions.js'; @@ -17,6 +18,7 @@ export class FileUploadBuilder extends ComponentBuilder * custom_id: "file_upload", * min_values: 2, * max_values: 5, + * file_types: ["image", ".pdf"], * }); * ``` * @example @@ -26,7 +28,9 @@ export class FileUploadBuilder extends ComponentBuilder * custom_id: "file_upload", * min_values: 2, * max_values: 5, - * }).setRequired(); + * }) + * .setFileTypes("image", ".pdf") + * .setRequired(); * ``` */ public constructor(data: Partial = {}) { @@ -79,6 +83,41 @@ export class FileUploadBuilder extends ComponentBuilder return this; } + /** + * Adds file types allowed in this file upload. + * + * @remarks + * When specifying only extensions, include `.jpg` for image uploads and both `.mp4` and `.mov` + * for video uploads due to mobile platform limitations. + * @param fileTypes - The file groups or dot-prefixed extensions to allow + */ + public addFileTypes(...fileTypes: RestOrArray) { + this.data.file_types ??= []; + this.data.file_types.push(...normalizeArray(fileTypes)); + return this; + } + + /** + * Sets the file types allowed in this file upload. + * + * @remarks + * When specifying only extensions, include `.jpg` for image uploads and both `.mp4` and `.mov` + * for video uploads due to mobile platform limitations. + * @param fileTypes - The file groups or dot-prefixed extensions to allow + */ + public setFileTypes(...fileTypes: RestOrArray) { + this.data.file_types = normalizeArray(fileTypes); + return this; + } + + /** + * Clears the file types allowed in this file upload. + */ + public clearFileTypes() { + this.data.file_types = undefined; + return this; + } + /** * Sets whether this file upload is required. * diff --git a/packages/builders/src/interactions/slashCommands/options/attachment.ts b/packages/builders/src/interactions/slashCommands/options/attachment.ts index cb31812f1..1d7641f87 100644 --- a/packages/builders/src/interactions/slashCommands/options/attachment.ts +++ b/packages/builders/src/interactions/slashCommands/options/attachment.ts @@ -1,4 +1,10 @@ -import { ApplicationCommandOptionType, type APIApplicationCommandAttachmentOption } from 'discord-api-types/v10'; +import { + ApplicationCommandOptionType, + type APIApplicationCommandAttachmentOption, + type FileUploadType, +} from 'discord-api-types/v10'; +import { fileUploadTypesValidator } from '../../../util/fileUpload.js'; +import { normalizeArray, type RestOrArray } from '../../../util/normalizeArray.js'; import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js'; /** @@ -10,6 +16,54 @@ export class SlashCommandAttachmentOption extends ApplicationCommandOptionBase { */ public override readonly type = ApplicationCommandOptionType.Attachment as const; + /** + * The file types allowed for this attachment option. + */ + public readonly file_types?: FileUploadType[]; + + /** + * Adds file types allowed for this attachment option. + * + * @remarks + * When specifying only extensions, include `.jpg` for image uploads and both `.mp4` and `.mov` + * for video uploads due to mobile platform limitations. + * @param fileTypes - The file groups or dot-prefixed extensions to allow + */ + public addFileTypes(...fileTypes: RestOrArray) { + const normalizedFileTypes = normalizeArray(fileTypes); + fileUploadTypesValidator.parse([...(this.file_types ?? []), ...normalizedFileTypes]); + + if (this.file_types === undefined) { + Reflect.set(this, 'file_types', []); + } + + this.file_types!.push(...normalizedFileTypes); + return this; + } + + /** + * Sets the file types allowed for this attachment option. + * + * @remarks + * When specifying only extensions, include `.jpg` for image uploads and both `.mp4` and `.mov` + * for video uploads due to mobile platform limitations. + * @param fileTypes - The file groups or dot-prefixed extensions to allow + */ + public setFileTypes(...fileTypes: RestOrArray) { + const normalizedFileTypes = normalizeArray(fileTypes); + fileUploadTypesValidator.parse(normalizedFileTypes); + Reflect.set(this, 'file_types', normalizedFileTypes); + return this; + } + + /** + * Clears the file types allowed for this attachment option. + */ + public clearFileTypes() { + Reflect.set(this, 'file_types', undefined); + return this; + } + /** * {@inheritDoc ApplicationCommandOptionBase.toJSON} */ diff --git a/packages/builders/src/util/fileUpload.ts b/packages/builders/src/util/fileUpload.ts new file mode 100644 index 000000000..547128b7d --- /dev/null +++ b/packages/builders/src/util/fileUpload.ts @@ -0,0 +1,14 @@ +import { s } from '@sapphire/shapeshift'; +import { isValidationEnabled } from './validation.js'; + +export const fileUploadTypesValidator = s + .array( + s.union([ + s.literal('audio'), + s.literal('image'), + s.literal('video'), + s.string().lengthGreaterThanOrEqual(2).regex(/^\./), + ]), + ) + .lengthLessThanOrEqual(10) + .setValidationEnabled(isValidationEnabled);