Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,4 +82,4 @@ const custom = emoji('<:hi:1105603587104591872>');

const url = custom.url();
// => 'https://cdn.discordapp.com/emojis/1105603587104591872.png'
```
```
12 changes: 3 additions & 9 deletions src/components/file.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
12 changes: 3 additions & 9 deletions src/components/image.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand All @@ -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;

Expand Down
81 changes: 75 additions & 6 deletions src/core/index.ts
Original file line number Diff line number Diff line change
@@ -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<APIInteractionResponseCallbackData['attachments']>,
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<APIInteractionResponseCallbackData['attachments']> = [];
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];
Expand All @@ -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,
};
}
10 changes: 10 additions & 0 deletions src/core/multipart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export interface DisUIMultipartFile {
name: string;
data: Buffer;
contentType?: string;
key?: string;
}

export interface DisUIResolvedFile extends DisUIMultipartFile {
key: string;
}
97 changes: 97 additions & 0 deletions src/tests/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ import {
container,
divider,
file,
gallery,
image,
mentionableSelect,
roleSelect,
row,
section,
select,
text,
userSelect,
Expand All @@ -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(
Expand Down Expand Up @@ -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,
},
]);
});
});