From 2037593d8d44b6e1299573ea711a5009cfa1cb71 Mon Sep 17 00:00:00 2001 From: Jiralite <33201955+Jiralite@users.noreply.github.com> Date: Mon, 20 Jul 2026 06:29:59 +0100 Subject: [PATCH] feat: `file_types` for builders (#11579) * feat: `file_types` * fix: .js extension Co-authored-by: Almeida * fix: adding * test: use `Reflect` * fix: update deprecated usage --------- Co-authored-by: Almeida Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .../__tests__/components/fileUpload.test.ts | 40 ++++++++++++++- .../ChatInputCommands/Options.test.ts | 37 +++++++++++++- packages/builders/src/Assertions.ts | 5 ++ .../src/components/fileUpload/Assertions.ts | 3 +- .../src/components/fileUpload/FileUpload.ts | 43 +++++++++++++++- .../commands/chatInput/Assertions.ts | 3 +- .../commands/chatInput/options/attachment.ts | 49 ++++++++++++++++++- 7 files changed, 173 insertions(+), 7 deletions(-) diff --git a/packages/builders/__tests__/components/fileUpload.test.ts b/packages/builders/__tests__/components/fileUpload.test.ts index 19b5113b1..8e25aac86 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'; @@ -15,6 +15,16 @@ describe('File Upload Components', () => { expect(() => { fileUploadComponent().setCustomId('foobar').setMinValues(2).setMaxValues(9).toJSON(); }).not.toThrowError(); + + expect(() => { + fileUploadComponent().setCustomId('foobar').setFileTypes('audio', 'image', 'video', '.pdf').toJSON(); + }).not.toThrow(); + }); + + test('GIVEN file types THEN they can be added', () => { + expect( + fileUploadComponent().setCustomId('foobar').addFileTypes('image').addFileTypes(['.pdf']).toJSON().file_types, + ).toEqual(['image', '.pdf']); }); }); @@ -34,6 +44,30 @@ describe('File Upload Components', () => { expect(() => { fileUploadComponent().setCustomId('a').setMinValues(-1).toJSON(); }).toThrowError(); + + expect(() => { + fileUploadComponent() + .setCustomId('a') + .setFileTypes(Array.from({ length: 11 }, () => '.txt' as const)) + .toJSON(); + }).toThrow(); + + for (const invalidFileType of ['document', 'pdf', '.']) { + expect(() => { + fileUploadComponent() + .setCustomId('a') + .setFileTypes(invalidFileType as FileUploadType) + .toJSON(); + }).toThrow(); + } + + expect(() => { + new FileUploadBuilder({ + type: ComponentType.FileUpload, + custom_id: 'a', + file_types: ['document' as FileUploadType], + }).toJSON(); + }).toThrow(); }); test('GIVEN valid input THEN valid JSON outputs are given', () => { @@ -42,6 +76,7 @@ describe('File Upload Components', () => { custom_id: 'custom id', min_values: 5, max_values: 6, + file_types: ['image', '.pdf'], required: false, } satisfies APIFileUploadComponent; @@ -52,8 +87,11 @@ describe('File Upload Components', () => { .setCustomId(fileUploadData.custom_id) .setMaxValues(fileUploadData.max_values) .setMinValues(fileUploadData.min_values) + .setFileTypes(fileUploadData.file_types) .setRequired(fileUploadData.required) .toJSON(), ).toEqual(fileUploadData); + + expect(new FileUploadBuilder(fileUploadData).clearFileTypes().toJSON().file_types).toBeUndefined(); }); }); diff --git a/packages/builders/__tests__/interactions/ChatInputCommands/Options.test.ts b/packages/builders/__tests__/interactions/ChatInputCommands/Options.test.ts index 4cef822aa..a9876cb8b 100644 --- a/packages/builders/__tests__/interactions/ChatInputCommands/Options.test.ts +++ b/packages/builders/__tests__/interactions/ChatInputCommands/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 { @@ -222,11 +223,45 @@ 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 calling toJSON should throw', () => { + expect(() => + getAttachmentOption() + .setFileTypes(Array.from({ length: 11 }, () => '.txt' as const)) + .toJSON(), + ).toThrow(); + }); + + test.each(['document', 'pdf', '.'])( + 'GIVEN invalid attachment file type %s THEN calling toJSON should throw', + (fileType) => { + expect(() => + getAttachmentOption() + .setFileTypes(fileType as FileUploadType) + .toJSON(), + ).toThrow(); + }, + ); }); diff --git a/packages/builders/src/Assertions.ts b/packages/builders/src/Assertions.ts index b5bfb3331..34950b887 100644 --- a/packages/builders/src/Assertions.ts +++ b/packages/builders/src/Assertions.ts @@ -5,6 +5,11 @@ export const idPredicate = z.int().min(0).max(2_147_483_647).optional(); export const customIdPredicate = z.string().min(1).max(100); export const snowflakePredicate = z.string().regex(/^(?:0|[1-9]\d*)$/); +export const fileUploadTypesPredicate = z + .union([z.enum(['audio', 'image', 'video']), z.string().min(2).startsWith('.')]) + .array() + .max(10); + export const memberPermissionsPredicate = z.coerce.bigint(); export const localeMapPredicate = z.strictObject( diff --git a/packages/builders/src/components/fileUpload/Assertions.ts b/packages/builders/src/components/fileUpload/Assertions.ts index 9ff00b8b8..784ab4c37 100644 --- a/packages/builders/src/components/fileUpload/Assertions.ts +++ b/packages/builders/src/components/fileUpload/Assertions.ts @@ -1,6 +1,6 @@ import { ComponentType } from 'discord-api-types/v10'; import { z } from 'zod'; -import { customIdPredicate, idPredicate } from '../../Assertions'; +import { customIdPredicate, fileUploadTypesPredicate, idPredicate } from '../../Assertions.js'; export const fileUploadPredicate = z.object({ type: z.literal(ComponentType.FileUpload), @@ -8,5 +8,6 @@ export const fileUploadPredicate = z.object({ custom_id: customIdPredicate, min_values: z.int().min(0).max(10).optional(), max_values: z.int().min(1).max(10).optional(), + file_types: fileUploadTypesPredicate.optional(), required: z.boolean().optional(), }); diff --git a/packages/builders/src/components/fileUpload/FileUpload.ts b/packages/builders/src/components/fileUpload/FileUpload.ts index feac1420b..09689f1f8 100644 --- a/packages/builders/src/components/fileUpload/FileUpload.ts +++ b/packages/builders/src/components/fileUpload/FileUpload.ts @@ -1,5 +1,6 @@ -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 { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js'; import { validate } from '../../util/validation.js'; import { ComponentBuilder } from '../Component.js'; import { fileUploadPredicate } from './Assertions.js'; @@ -24,6 +25,7 @@ export class FileUploadBuilder extends ComponentBuilder * custom_id: "file_upload", * min_values: 2, * max_values: 5, + * file_types: ["image", ".pdf"], * }); * ``` * @example @@ -33,7 +35,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 = {}) { @@ -87,6 +91,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/commands/chatInput/Assertions.ts b/packages/builders/src/interactions/commands/chatInput/Assertions.ts index 68650119c..d8695b647 100644 --- a/packages/builders/src/interactions/commands/chatInput/Assertions.ts +++ b/packages/builders/src/interactions/commands/chatInput/Assertions.ts @@ -5,7 +5,7 @@ import { ApplicationCommandType, } from 'discord-api-types/v10'; import { z } from 'zod'; -import { localeMapPredicate, memberPermissionsPredicate } from '../../../Assertions.js'; +import { fileUploadTypesPredicate, localeMapPredicate, memberPermissionsPredicate } from '../../../Assertions.js'; import { ApplicationCommandOptionAllowedChannelTypes } from './mixins/ApplicationCommandOptionChannelTypesMixin.js'; const namePredicate = z @@ -77,6 +77,7 @@ export const baseBasicOptionPredicate = z.object({ export const attachmentOptionPredicate = z.object({ ...baseBasicOptionPredicate.shape, type: z.literal(ApplicationCommandOptionType.Attachment), + file_types: fileUploadTypesPredicate.optional(), }); export const booleanOptionPredicate = z.object({ diff --git a/packages/builders/src/interactions/commands/chatInput/options/attachment.ts b/packages/builders/src/interactions/commands/chatInput/options/attachment.ts index 097c67f29..1b807e432 100644 --- a/packages/builders/src/interactions/commands/chatInput/options/attachment.ts +++ b/packages/builders/src/interactions/commands/chatInput/options/attachment.ts @@ -1,6 +1,12 @@ -import { ApplicationCommandOptionType } from 'discord-api-types/v10'; +import { + ApplicationCommandOptionType, + type APIApplicationCommandAttachmentOption, + type FileUploadType, +} from 'discord-api-types/v10'; +import { normalizeArray, type RestOrArray } from '../../../../util/normalizeArray.js'; import { attachmentOptionPredicate } from '../Assertions.js'; import { ApplicationCommandOptionBase } from './ApplicationCommandOptionBase.js'; +import type { ApplicationCommandOptionBaseData } from './ApplicationCommandOptionBase.js'; /** * A chat input command attachment option. @@ -11,10 +17,51 @@ export class ChatInputCommandAttachmentOption extends ApplicationCommandOptionBa */ protected static override readonly predicate = attachmentOptionPredicate; + /** + * @internal + */ + declare protected readonly data: ApplicationCommandOptionBaseData & + Partial>; + /** * Creates a new attachment option. */ public constructor() { super(ApplicationCommandOptionType.Attachment); } + + /** + * 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) { + this.data.file_types ??= []; + this.data.file_types.push(...normalizeArray(fileTypes)); + 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) { + this.data.file_types = normalizeArray(fileTypes); + return this; + } + + /** + * Clears the file types allowed for this attachment option. + */ + public clearFileTypes() { + this.data.file_types = undefined; + return this; + } }