@lunibee/builders 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/attachment.ts +11 -2
- package/src/clone.ts +27 -0
- package/src/commands.ts +26 -5
- package/src/components.ts +44 -14
- package/src/embed.ts +13 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunibee/builders",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"module": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
".": "./src/index.ts"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@lunibee/types": "^0.3.
|
|
11
|
+
"@lunibee/types": "^0.3.2"
|
|
12
12
|
}
|
|
13
13
|
}
|
package/src/attachment.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile, realpath } from "node:fs/promises";
|
|
2
2
|
import { basename, isAbsolute, relative, resolve } from "node:path";
|
|
3
3
|
|
|
4
|
+
/** Options for `CreateAttachment`: file name, alt-text description, the contents or a file path, and an optional root folder the path must stay inside. */
|
|
4
5
|
export interface AttachmentData {
|
|
5
6
|
name: string;
|
|
6
7
|
description?: string;
|
|
@@ -84,12 +85,20 @@ export class CreateAttachment {
|
|
|
84
85
|
|
|
85
86
|
/** Resolves `file` against `root`, refusing anything outside it (after following symlinks). */
|
|
86
87
|
async function insideRoot(root: string, file: string): Promise<string> {
|
|
87
|
-
const base = await realpath(root)
|
|
88
|
+
const base = await realpath(root).catch(() => {
|
|
89
|
+
throw new Error("Attachment root folder was not found.");
|
|
90
|
+
});
|
|
88
91
|
const target = await realpath(resolve(base, file)).catch(() => {
|
|
89
92
|
throw new Error("Attachment file was not found inside its root.");
|
|
90
93
|
});
|
|
91
94
|
const rel = relative(base, target);
|
|
92
|
-
if (
|
|
95
|
+
if (
|
|
96
|
+
rel === "" ||
|
|
97
|
+
rel === ".." ||
|
|
98
|
+
rel.startsWith("../") ||
|
|
99
|
+
rel.startsWith("..\\") ||
|
|
100
|
+
isAbsolute(rel)
|
|
101
|
+
)
|
|
93
102
|
throw new Error("Attachment file is outside its root folder.");
|
|
94
103
|
return target;
|
|
95
104
|
}
|
package/src/clone.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deep copy of builder data, for `toJSON()`.
|
|
3
|
+
*
|
|
4
|
+
* Builder payloads are plain JSON: objects, arrays and primitives. Copying
|
|
5
|
+
* those directly is several times faster than `structuredClone` on the small
|
|
6
|
+
* payloads builders hold. Anything else (a Date, a Map, a class instance) and
|
|
7
|
+
* anything nested deeper than 64 levels (a cycle) goes to `structuredClone`,
|
|
8
|
+
* so the result is what `structuredClone` would give.
|
|
9
|
+
*/
|
|
10
|
+
export function cloneData<T>(value: T, depth = 0): T {
|
|
11
|
+
if (value === null || typeof value !== "object") return value;
|
|
12
|
+
if (depth > 64) return structuredClone(value);
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
const out = new Array(value.length);
|
|
15
|
+
for (let i = 0; i < value.length; i++)
|
|
16
|
+
out[i] = cloneData(value[i], depth + 1);
|
|
17
|
+
return out as T;
|
|
18
|
+
}
|
|
19
|
+
const proto = Object.getPrototypeOf(value);
|
|
20
|
+
if (proto !== Object.prototype && proto !== null)
|
|
21
|
+
return structuredClone(value);
|
|
22
|
+
const from = value as Record<string, unknown>;
|
|
23
|
+
const out: Record<string, unknown> = {};
|
|
24
|
+
for (const key of Object.keys(from))
|
|
25
|
+
out[key] = cloneData(from[key], depth + 1);
|
|
26
|
+
return out as T;
|
|
27
|
+
}
|
package/src/commands.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { cloneData } from "./clone.js";
|
|
1
2
|
/** Discord application command option types. */
|
|
2
3
|
export const ApplicationCommandOptionEnum = {
|
|
3
4
|
Subcommand: 1,
|
|
@@ -137,7 +138,7 @@ export class CreateSlashCommand {
|
|
|
137
138
|
}
|
|
138
139
|
/** Serializes the command payload. */
|
|
139
140
|
public toJSON(): Record<string, unknown> {
|
|
140
|
-
return
|
|
141
|
+
return cloneData(this.#data);
|
|
141
142
|
}
|
|
142
143
|
/** Adds a validated top-level command option. */
|
|
143
144
|
protected addOption(option: CreateCommandOption): this {
|
|
@@ -174,14 +175,20 @@ export class CreateCommandOption {
|
|
|
174
175
|
this.data.required = required;
|
|
175
176
|
return this;
|
|
176
177
|
}
|
|
177
|
-
/** Sets autocomplete. */
|
|
178
|
+
/** Sets autocomplete; options with choices cannot use it. */
|
|
178
179
|
public setAutocomplete(enabled = true): this {
|
|
180
|
+
if (
|
|
181
|
+
enabled &&
|
|
182
|
+
Array.isArray(this.data.choices) &&
|
|
183
|
+
this.data.choices.length
|
|
184
|
+
)
|
|
185
|
+
throw new RangeError("Autocomplete options cannot define choices.");
|
|
179
186
|
this.data.autocomplete = enabled;
|
|
180
187
|
return this;
|
|
181
188
|
}
|
|
182
189
|
/** Serializes the option payload. */
|
|
183
190
|
public toJSON(): Record<string, unknown> {
|
|
184
|
-
return
|
|
191
|
+
return cloneData(this.data);
|
|
185
192
|
}
|
|
186
193
|
}
|
|
187
194
|
|
|
@@ -235,6 +242,9 @@ export class CreateNumericOption extends CreateCommandOption {
|
|
|
235
242
|
"min_value",
|
|
236
243
|
this.#integer,
|
|
237
244
|
);
|
|
245
|
+
const max = this.data.max_value as number | undefined;
|
|
246
|
+
if (max !== undefined && value > max)
|
|
247
|
+
throw new RangeError("min_value cannot exceed max_value.");
|
|
238
248
|
this.data.min_value = value;
|
|
239
249
|
return this;
|
|
240
250
|
}
|
|
@@ -490,7 +500,18 @@ function appendChoices(
|
|
|
490
500
|
throw new RangeError("Autocomplete options cannot define choices.");
|
|
491
501
|
if (current.length + choices.length > 25)
|
|
492
502
|
throw new RangeError("An option cannot contain more than 25 choices.");
|
|
493
|
-
for (const choice of choices)
|
|
503
|
+
for (const choice of choices) {
|
|
504
|
+
validateText(choice.name, "Choice name", 100);
|
|
505
|
+
const value: unknown = choice.value;
|
|
506
|
+
if (
|
|
507
|
+
typeof value === "string"
|
|
508
|
+
? value.length === 0 || value.length > 100
|
|
509
|
+
: typeof value !== "number" || !Number.isFinite(value)
|
|
510
|
+
)
|
|
511
|
+
throw new TypeError(
|
|
512
|
+
"Choice value must be a string of 1-100 characters or a finite number.",
|
|
513
|
+
);
|
|
514
|
+
}
|
|
494
515
|
data.choices = [...current, ...choices.map((choice) => ({ ...choice }))];
|
|
495
516
|
}
|
|
496
517
|
|
|
@@ -596,7 +617,7 @@ export class CreateContextMenuCommand {
|
|
|
596
617
|
}
|
|
597
618
|
/** Serializes the command payload for the Discord API. */
|
|
598
619
|
public toJSON(): Record<string, unknown> {
|
|
599
|
-
return
|
|
620
|
+
return cloneData(this.data);
|
|
600
621
|
}
|
|
601
622
|
}
|
|
602
623
|
|
package/src/components.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
+
import { cloneData } from "./clone.js";
|
|
1
2
|
import { ButtonType, ComponentEnum } from "@lunibee/types";
|
|
2
3
|
export { ButtonType, ComponentEnum };
|
|
3
4
|
|
|
4
5
|
export const TextInputType = { Short: 1, Paragraph: 2 } as const;
|
|
5
6
|
/** One {@link TextInputType} value, usable as a type. */
|
|
6
7
|
export type TextInputType = (typeof TextInputType)[keyof typeof TextInputType];
|
|
8
|
+
/** An emoji on a button or select option: a custom emoji `id`, or a Unicode `name`. */
|
|
7
9
|
export interface APIComponentEmoji {
|
|
8
10
|
id?: string | null;
|
|
9
11
|
name?: string | null;
|
|
10
12
|
animated?: boolean;
|
|
11
13
|
}
|
|
14
|
+
/** A button as a builder sends it. Exported from `lunibee` as `APIButtonPayload`. */
|
|
12
15
|
export interface APIButtonComponent {
|
|
13
16
|
type: typeof ComponentEnum.Button;
|
|
14
17
|
style: 1 | 2 | 3 | 4 | 5 | 6;
|
|
@@ -20,6 +23,7 @@ export interface APIButtonComponent {
|
|
|
20
23
|
sku_id?: string;
|
|
21
24
|
disabled?: boolean;
|
|
22
25
|
}
|
|
26
|
+
/** One option of a string select menu. */
|
|
23
27
|
export interface APISelectOption {
|
|
24
28
|
label: string;
|
|
25
29
|
value: string;
|
|
@@ -27,6 +31,7 @@ export interface APISelectOption {
|
|
|
27
31
|
emoji?: APIComponentEmoji;
|
|
28
32
|
default?: boolean;
|
|
29
33
|
}
|
|
34
|
+
/** A string select menu (component type 3) as Discord receives it. */
|
|
30
35
|
export interface APIStringSelectComponent {
|
|
31
36
|
type: typeof ComponentEnum.StringSelect;
|
|
32
37
|
custom_id?: string;
|
|
@@ -37,6 +42,7 @@ export interface APIStringSelectComponent {
|
|
|
37
42
|
disabled?: boolean;
|
|
38
43
|
options?: APISelectOption[];
|
|
39
44
|
}
|
|
45
|
+
/** A user, role, mentionable or channel select menu as Discord receives it. */
|
|
40
46
|
export interface APIEntitySelectComponent {
|
|
41
47
|
type:
|
|
42
48
|
| typeof ComponentEnum.UserSelect
|
|
@@ -51,6 +57,7 @@ export interface APIEntitySelectComponent {
|
|
|
51
57
|
disabled?: boolean;
|
|
52
58
|
default_values?: APISelectDefaultValue[];
|
|
53
59
|
}
|
|
60
|
+
/** A modal text input as a builder sends it. Exported from `lunibee` as `APITextInputPayload`. */
|
|
54
61
|
export interface APITextInputComponent {
|
|
55
62
|
type: typeof ComponentEnum.TextInput;
|
|
56
63
|
style: typeof TextInputType.Short | typeof TextInputType.Paragraph;
|
|
@@ -62,68 +69,81 @@ export interface APITextInputComponent {
|
|
|
62
69
|
required?: boolean;
|
|
63
70
|
value?: string;
|
|
64
71
|
}
|
|
72
|
+
/** A modal (callback type 9 data): title, custom ID and its components. */
|
|
65
73
|
export interface APIModalComponent {
|
|
66
74
|
type: 9;
|
|
67
75
|
custom_id: string;
|
|
68
76
|
title: string;
|
|
69
77
|
components: APIActionRowComponent[];
|
|
70
78
|
}
|
|
79
|
+
/** Any component an action row can hold: a button, a select menu or a text input. */
|
|
71
80
|
export type APIActionRowChild =
|
|
72
81
|
| APIButtonComponent
|
|
73
82
|
| APIStringSelectComponent
|
|
74
83
|
| APIEntitySelectComponent
|
|
75
84
|
| APITextInputComponent;
|
|
85
|
+
/** An action row as a builder sends it: up to five buttons, or one select menu or text input. Exported from `lunibee` as `APIActionRowPayload`. */
|
|
76
86
|
export interface APIActionRowComponent {
|
|
77
87
|
type: typeof ComponentEnum.ActionRow;
|
|
78
88
|
components: APIActionRowChild[];
|
|
79
89
|
}
|
|
90
|
+
/** A Components V2 section: up to three text displays with an accessory. */
|
|
80
91
|
export interface APISectionComponent {
|
|
81
92
|
type: typeof ComponentEnum.Section;
|
|
82
93
|
components?: APIComponent[]; // text displays
|
|
83
94
|
accessory?: APIComponent; // buttons, thumbnails, etc.
|
|
84
95
|
}
|
|
96
|
+
/** A Components V2 text display (Markdown content). */
|
|
85
97
|
export interface APITextDisplayComponent {
|
|
86
98
|
type: typeof ComponentEnum.TextDisplay;
|
|
87
99
|
content: string;
|
|
88
100
|
}
|
|
101
|
+
/** A Components V2 media gallery; each item is `{ media: { url }, description?, spoiler? }`. */
|
|
89
102
|
export interface APIMediaGalleryComponent {
|
|
90
103
|
type: typeof ComponentEnum.MediaGallery;
|
|
91
|
-
items: {
|
|
104
|
+
items: {
|
|
105
|
+
media: { url: string };
|
|
106
|
+
description?: string;
|
|
107
|
+
spoiler?: boolean;
|
|
108
|
+
}[];
|
|
92
109
|
}
|
|
93
110
|
/** Default values accepted by an auto-populated entity select. */
|
|
94
111
|
export interface APISelectDefaultValue {
|
|
95
112
|
id: string;
|
|
96
113
|
type: "user" | "role" | "channel";
|
|
97
114
|
}
|
|
115
|
+
/** A Components V2 file display for an uploaded attachment. */
|
|
98
116
|
export interface APIFileComponent {
|
|
99
117
|
type: typeof ComponentEnum.File;
|
|
100
118
|
file: { url: string };
|
|
101
119
|
spoiler?: boolean;
|
|
102
120
|
}
|
|
121
|
+
/** A Components V2 separator, with optional divider line and spacing. */
|
|
103
122
|
export interface APISeparatorComponent {
|
|
104
123
|
type: typeof ComponentEnum.Separator;
|
|
105
124
|
spacing?: 1 | 2;
|
|
106
125
|
divider?: boolean;
|
|
107
126
|
}
|
|
127
|
+
/** A Components V2 thumbnail: `{ type: 11, media: { url }, description?, spoiler? }`. */
|
|
108
128
|
export interface APIThumbnailComponent {
|
|
109
129
|
type: typeof ComponentEnum.Thumbnail;
|
|
110
|
-
url: string;
|
|
111
|
-
proxy_url?: string;
|
|
112
|
-
width?: number;
|
|
113
|
-
height?: number;
|
|
130
|
+
media: { url: string };
|
|
114
131
|
description?: string;
|
|
115
132
|
spoiler?: boolean;
|
|
116
133
|
}
|
|
134
|
+
/** A Components V2 content inventory entry. */
|
|
117
135
|
export interface APIContentInventoryEntryComponent {
|
|
118
136
|
type: typeof ComponentEnum.ContentInventoryEntry;
|
|
119
137
|
id: string;
|
|
120
138
|
}
|
|
139
|
+
/** A Components V2 container: a box around other components, with an optional accent colour. */
|
|
121
140
|
export interface APIContainerComponent {
|
|
122
141
|
type: typeof ComponentEnum.Container;
|
|
123
142
|
components: APIComponent[];
|
|
124
143
|
accent_color?: number;
|
|
125
144
|
spoiler?: boolean;
|
|
126
145
|
}
|
|
146
|
+
/** Any top-level message component: an action row or a Components V2 layout component. */
|
|
127
147
|
export type APIComponent =
|
|
128
148
|
| APIActionRowComponent
|
|
129
149
|
| APIButtonComponent
|
|
@@ -253,7 +273,7 @@ export class CreateStringSelect extends CreateSelectMenu {
|
|
|
253
273
|
throw new RangeError(
|
|
254
274
|
"max_values cannot exceed the number of options.",
|
|
255
275
|
);
|
|
256
|
-
return
|
|
276
|
+
return cloneData(this.#data);
|
|
257
277
|
}
|
|
258
278
|
}
|
|
259
279
|
export class CreateButton {
|
|
@@ -301,6 +321,8 @@ export class CreateButton {
|
|
|
301
321
|
cause: error,
|
|
302
322
|
});
|
|
303
323
|
}
|
|
324
|
+
if (!["http:", "https:", "discord:"].includes(url.protocol))
|
|
325
|
+
throw new TypeError("Button URL must use http, https or discord.");
|
|
304
326
|
this.setStyle(ButtonType.Link);
|
|
305
327
|
this.#data.url = url.toString();
|
|
306
328
|
return this;
|
|
@@ -346,7 +368,7 @@ export class CreateButton {
|
|
|
346
368
|
}
|
|
347
369
|
if (data.style !== ButtonType.Premium && !data.label && !data.emoji)
|
|
348
370
|
throw new TypeError("Buttons require a label or an emoji.");
|
|
349
|
-
return
|
|
371
|
+
return cloneData(data);
|
|
350
372
|
}
|
|
351
373
|
}
|
|
352
374
|
export class CreateEntitySelect extends CreateSelectMenu {
|
|
@@ -375,7 +397,7 @@ export class CreateEntitySelect extends CreateSelectMenu {
|
|
|
375
397
|
}
|
|
376
398
|
public toJSON(): APIEntitySelectComponent {
|
|
377
399
|
assertSelect(this.#data);
|
|
378
|
-
return
|
|
400
|
+
return cloneData(this.#data);
|
|
379
401
|
}
|
|
380
402
|
}
|
|
381
403
|
export class CreateModal {
|
|
@@ -478,7 +500,7 @@ export class CreateTextInput {
|
|
|
478
500
|
data.min_length > data.max_length
|
|
479
501
|
)
|
|
480
502
|
throw new RangeError("min_length cannot exceed max_length.");
|
|
481
|
-
return
|
|
503
|
+
return cloneData(data);
|
|
482
504
|
}
|
|
483
505
|
}
|
|
484
506
|
|
|
@@ -558,8 +580,10 @@ export class CreateTextDisplay {
|
|
|
558
580
|
}
|
|
559
581
|
|
|
560
582
|
export class CreateMediaGallery {
|
|
561
|
-
readonly #items:
|
|
562
|
-
public addItems(
|
|
583
|
+
readonly #items: APIMediaGalleryComponent["items"] = [];
|
|
584
|
+
public addItems(
|
|
585
|
+
...items: { url: string; description?: string; spoiler?: boolean }[]
|
|
586
|
+
): this {
|
|
563
587
|
if (!items.length) {
|
|
564
588
|
throw new TypeError("At least one media gallery item is required.");
|
|
565
589
|
}
|
|
@@ -568,13 +592,19 @@ export class CreateMediaGallery {
|
|
|
568
592
|
"MediaGallery can only contain up to 10 items.",
|
|
569
593
|
);
|
|
570
594
|
}
|
|
571
|
-
this.#items.push(
|
|
595
|
+
this.#items.push(
|
|
596
|
+
...items.map(({ url, description, spoiler }) => ({
|
|
597
|
+
media: { url },
|
|
598
|
+
...(description !== undefined ? { description } : {}),
|
|
599
|
+
...(spoiler !== undefined ? { spoiler } : {}),
|
|
600
|
+
})),
|
|
601
|
+
);
|
|
572
602
|
return this;
|
|
573
603
|
}
|
|
574
604
|
public toJSON(): APIMediaGalleryComponent {
|
|
575
605
|
return {
|
|
576
606
|
type: ComponentEnum.MediaGallery,
|
|
577
|
-
items:
|
|
607
|
+
items: cloneData(this.#items),
|
|
578
608
|
};
|
|
579
609
|
}
|
|
580
610
|
}
|
|
@@ -646,7 +676,7 @@ export class CreateThumbnail {
|
|
|
646
676
|
public toJSON(): APIThumbnailComponent {
|
|
647
677
|
const data: APIThumbnailComponent = {
|
|
648
678
|
type: ComponentEnum.Thumbnail,
|
|
649
|
-
url: this.#url,
|
|
679
|
+
media: { url: this.#url },
|
|
650
680
|
};
|
|
651
681
|
if (this.#description !== undefined)
|
|
652
682
|
data.description = this.#description;
|
package/src/embed.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { cloneData } from "./clone.js";
|
|
1
2
|
/** Discord API embed field payload. */
|
|
2
3
|
export interface EmbedField {
|
|
3
4
|
/** Field name. */
|
|
@@ -171,7 +172,11 @@ export class CreateEmbed {
|
|
|
171
172
|
throw new RangeError(
|
|
172
173
|
"Field index and delete count must be non-negative integers.",
|
|
173
174
|
);
|
|
174
|
-
|
|
175
|
+
for (const field of fields) {
|
|
176
|
+
validate(field.name, 256, "Embed field name");
|
|
177
|
+
validate(field.value, 1024, "Embed field value");
|
|
178
|
+
}
|
|
179
|
+
current.splice(index, deleteCount, ...fields.map((f) => ({ ...f })));
|
|
175
180
|
if (current.length > 25)
|
|
176
181
|
throw new RangeError(
|
|
177
182
|
"An embed cannot contain more than 25 fields.",
|
|
@@ -214,7 +219,7 @@ export class CreateEmbed {
|
|
|
214
219
|
throw new RangeError(
|
|
215
220
|
`An embed cannot contain more than 6000 characters in total (this one has ${total}).`,
|
|
216
221
|
);
|
|
217
|
-
return
|
|
222
|
+
return cloneData(data);
|
|
218
223
|
}
|
|
219
224
|
}
|
|
220
225
|
/** Validates a bounded Discord string field. @param value Field value. @param max Maximum length. @param field Human-readable field name. @returns Validated value. @throws {RangeError} If length is invalid. */
|
|
@@ -225,9 +230,14 @@ function validate(value: string, max: number, field: string): string {
|
|
|
225
230
|
}
|
|
226
231
|
/** Validates and normalizes an absolute URL. @param value URL value. @param field Human-readable field name. @returns Normalized URL. @throws {TypeError} If URL is invalid. */
|
|
227
232
|
function url(value: string, field: string): string {
|
|
233
|
+
let parsed: URL;
|
|
228
234
|
try {
|
|
229
|
-
|
|
235
|
+
parsed = new URL(value);
|
|
230
236
|
} catch (error) {
|
|
231
237
|
throw new TypeError(`${field} must be a valid URL.`, { cause: error });
|
|
232
238
|
}
|
|
239
|
+
// `attachment://` points an image at an uploaded file.
|
|
240
|
+
if (!["http:", "https:", "attachment:"].includes(parsed.protocol))
|
|
241
|
+
throw new TypeError(`${field} must use http, https or attachment.`);
|
|
242
|
+
return parsed.toString();
|
|
233
243
|
}
|