From e477aab5810ab23892dd728323dc1e8e13c26cde Mon Sep 17 00:00:00 2001 From: cezary17 Date: Thu, 16 Apr 2026 21:58:19 +0200 Subject: [PATCH] feat: collect multipart attachment uploads --- README.md | 27 ++++++++++- src/components/file.ts | 12 ++--- src/components/image.ts | 12 ++--- src/core/index.ts | 81 ++++++++++++++++++++++++++++++--- src/core/multipart.ts | 10 +++++ src/tests/render.test.ts | 97 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 214 insertions(+), 25 deletions(-) create mode 100644 src/core/multipart.ts diff --git a/README.md b/README.md index e21066f..c178ce9 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,31 @@ const resolved = resolveDisUI(message) // => { components: APIMessageComponent[], flags: 32768 } ``` +## Multipart Uploads + +```ts +import { file, image, resolveDisUI, section, text, ui } from 'disui'; + +const resolved = resolveDisUI( + ui( + file({ + name: 'report.txt', + data: Buffer.from('hello world'), + contentType: 'text/plain', + }), + section(text('Preview'), image({ name: 'preview.png', data: Buffer.from('...') })), + ), +); + +resolved.attachments; +// => [{ id: 0, filename: 'report.txt' }, { id: 1, filename: 'preview.png' }] + +resolved.files; +// => [{ name: 'report.txt', data: Buffer, key: 'files[0]' }, ...] +``` + +`attachments` contains the JSON metadata Discord expects, and `files` contains the multipart uploads to send as `files[n]` form parts. + ## Utils ```ts @@ -57,4 +82,4 @@ const custom = emoji('<:hi:1105603587104591872>'); const url = custom.url(); // => 'https://cdn.discordapp.com/emojis/1105603587104591872.png' -``` \ No newline at end of file +``` diff --git a/src/components/file.ts b/src/components/file.ts index f967ec1..2e1d71e 100644 --- a/src/components/file.ts +++ b/src/components/file.ts @@ -1,24 +1,18 @@ +import type { DisUIMultipartFile } from '../core/multipart'; import { type ComponentBase, constructComponent } from '../internal'; -type MultipartFile = { - name: string; - data: Buffer; - contentType?: string; - key?: string; -}; - export interface FileComponent extends ComponentBase< 'File', { - file: { url: string | MultipartFile }; + file: { url: string | DisUIMultipartFile }; spoiler: boolean | undefined; } > { spoiler: (condition?: boolean) => this; } -export function file(url: string | MultipartFile | { url: string }): FileComponent { +export function file(url: string | DisUIMultipartFile | { url: string }): FileComponent { let spoilerVar: boolean | undefined; const output = { diff --git a/src/components/image.ts b/src/components/image.ts index efc8266..089a5d7 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -1,19 +1,13 @@ import { DisUIComponentType } from '../core/constants'; +import type { DisUIMultipartFile } from '../core/multipart'; import { type ComponentBase, constructComponent } from '../internal'; -type MultipartFile = { - name: string; - data: Buffer; - contentType?: string; - key?: string; -}; - export interface ImageComponent extends ComponentBase< 'Thumbnail', { type: number | undefined; - media: { url: string | MultipartFile }; + media: { url: string | DisUIMultipartFile }; description: string | undefined; spoiler: boolean | undefined; } @@ -22,7 +16,7 @@ export interface ImageComponent spoiler: (condition?: boolean) => this; } -export function image(url: string | MultipartFile | { url: string }): ImageComponent { +export function image(url: string | DisUIMultipartFile | { url: string }): ImageComponent { let altVar: string | undefined; let spoilerVar: boolean | undefined; diff --git a/src/core/index.ts b/src/core/index.ts index d5d380d..9c633f0 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,15 +1,86 @@ import { AllowedMentionsTypes, type APIInteractionResponseCallbackData, + type APIMessageComponent, type APIMessageTopLevelComponent, + ComponentType, MessageFlags, } from 'discord-api-types/v10'; import type { DisUIAllowedMentionType } from '../components/ui'; import { render } from '../internal'; +import { walkComponents } from '../util'; import { type DisUIComponent, DisUIComponentType, DisUISymbol } from './constants'; +import type { DisUIResolvedFile, DisUIMultipartFile } from './multipart'; -export function resolveDisUI(component: DisUIComponent): APIInteractionResponseCallbackData { +export * from './multipart'; + +export interface ResolvedDisUI extends APIInteractionResponseCallbackData { + files: DisUIResolvedFile[]; +} + +function isMultipartFile(value: unknown): value is DisUIMultipartFile { + return ( + typeof value === 'object' && value !== null && 'name' in value && 'data' in value && Buffer.isBuffer(value.data) + ); +} + +function attachmentUrl(name: string) { + return `attachment://${name}`; +} + +function collectAttachment( + media: { url: string | DisUIMultipartFile }, + attachments: NonNullable, + files: DisUIResolvedFile[], +) { + if (!isMultipartFile(media.url)) { + return; + } + + const index = attachments.length; + const file = media.url; + + attachments.push({ + id: index, + filename: file.name, + }); + files.push({ + ...file, + key: file.key ?? `files[${index}]`, + }); + media.url = attachmentUrl(file.name); +} + +function resolveAttachments(components: APIMessageTopLevelComponent[]) { + const attachments: NonNullable = []; + const files: DisUIResolvedFile[] = []; + + walkComponents(components as APIMessageComponent[], (component) => { + if (component.type === ComponentType.File) { + collectAttachment(component.file, attachments, files); + } + + if (component.type === ComponentType.Thumbnail) { + collectAttachment(component.media, attachments, files); + } + + if (component.type === ComponentType.MediaGallery) { + for (const item of component.items) { + collectAttachment(item.media, attachments, files); + } + } + }); + + return { attachments, files }; +} + +export function resolveDisUI(component: DisUIComponent): ResolvedDisUI { const rendered = component[DisUISymbol].render({ stack: [], context: {} }); + const components = + component[DisUISymbol].type === DisUIComponentType.UI + ? (rendered.components as APIMessageTopLevelComponent[]) + : (render(component) as APIMessageTopLevelComponent[]); + const { attachments, files } = resolveAttachments(components); let flags = MessageFlags.IsComponentsV2; let allowedMentions: AllowedMentionsTypes[] = [AllowedMentionsTypes.User]; @@ -33,14 +104,12 @@ export function resolveDisUI(component: DisUIComponent): APIInteractionResponseC } return { - components: - component[DisUISymbol].type === DisUIComponentType.UI - ? (rendered.components as APIMessageTopLevelComponent[]) - : (render(component) as APIMessageTopLevelComponent[]), + components, flags, - attachments: [], + attachments, allowed_mentions: { parse: allowedMentions, }, + files, }; } diff --git a/src/core/multipart.ts b/src/core/multipart.ts new file mode 100644 index 0000000..9f62602 --- /dev/null +++ b/src/core/multipart.ts @@ -0,0 +1,10 @@ +export interface DisUIMultipartFile { + name: string; + data: Buffer; + contentType?: string; + key?: string; +} + +export interface DisUIResolvedFile extends DisUIMultipartFile { + key: string; +} diff --git a/src/tests/render.test.ts b/src/tests/render.test.ts index 98bfbe0..f82deed 100644 --- a/src/tests/render.test.ts +++ b/src/tests/render.test.ts @@ -12,9 +12,12 @@ import { container, divider, file, + gallery, + image, mentionableSelect, roleSelect, row, + section, select, text, userSelect, @@ -39,6 +42,22 @@ function getRowChildren(component: APIMessageComponent) { return component.components; } +function getGalleryItems(component: APIMessageComponent) { + if (component.type !== ComponentType.MediaGallery) { + throw new Error('Expected a media gallery'); + } + + return component.items; +} + +function getSectionAccessory(component: APIMessageComponent) { + if (component.type !== ComponentType.Section) { + throw new Error('Expected a section'); + } + + return component.accessory; +} + describe('render', () => { it('renders ui flags and mentions', () => { const message = ui( @@ -165,5 +184,83 @@ describe('render', () => { file: { url: 'attachment://example.txt' }, spoiler: true, }); + expect(resolved.attachments).toEqual([]); + expect(resolved.files).toEqual([]); + }); + + it('collects multipart files and rewrites attachment urls', () => { + const reportFile = { + name: 'report.txt', + data: Buffer.from('report'), + contentType: 'text/plain', + }; + const sectionImage = { + name: 'thumbnail.png', + data: Buffer.from('thumbnail'), + contentType: 'image/png', + }; + const galleryImage = { + name: 'gallery.png', + data: Buffer.from('gallery'), + contentType: 'image/png', + key: 'files[9]', + }; + const message = ui( + container( + file(reportFile).spoiler(), + section(text('Preview'), image(sectionImage).alt('Section image')), + gallery(image(galleryImage).alt('Gallery image').spoiler()), + ), + ); + + const resolved = resolveDisUI(message); + const containerChildren = getContainerChildren(resolved.components[0]); + + const multipartFileComponent = containerChildren[0]; + if (multipartFileComponent.type !== ComponentType.File) { + throw new Error('Expected a file component'); + } + + expect(multipartFileComponent).toMatchObject({ + file: { url: 'attachment://report.txt' }, + spoiler: true, + }); + + const sectionAccessory = getSectionAccessory(containerChildren[1]); + if (sectionAccessory.type !== ComponentType.Thumbnail) { + throw new Error('Expected a thumbnail accessory'); + } + + expect(sectionAccessory).toMatchObject({ + media: { url: 'attachment://thumbnail.png' }, + description: 'Section image', + }); + + expect(getGalleryItems(containerChildren[2])).toMatchObject([ + { + media: { url: 'attachment://gallery.png' }, + description: 'Gallery image', + spoiler: true, + }, + ]); + + expect(resolved.attachments).toEqual([ + { id: 0, filename: 'report.txt' }, + { id: 1, filename: 'thumbnail.png' }, + { id: 2, filename: 'gallery.png' }, + ]); + expect(resolved.files).toEqual([ + { + ...reportFile, + key: 'files[0]', + }, + { + ...sectionImage, + key: 'files[1]', + }, + { + ...galleryImage, + }, + ]); }); });