@kevlid/discordmenus 0.0.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.
@@ -0,0 +1,157 @@
1
+ const { Builder } = require("./builder");
2
+ const { OptionTypes } = require("./utils/types");
3
+ const { CategoryBuilder } = require("./options/category");
4
+ const { StringBuilder } = require("./options/string");
5
+ const { NumberBuilder } = require("./options/number");
6
+ const { BooleanBuilder } = require("./options/boolean");
7
+ const { StringListBuilder, NumberListBuilder, ObjectListBuilder } = require("./options/list");
8
+ const { ObjectBuilder } = require("./options/object");
9
+ const { StringSelectBuilder, UserSelectBuilder, RoleSelectBuilder, MentionableSelectBuilder, ChannelSelectBuilder } = require("./options/selectMenu");
10
+
11
+
12
+ class MenuBuilder extends Builder {
13
+ constructor() {
14
+ super(OptionTypes.Menu, 2);
15
+ this.categories = [];
16
+ }
17
+
18
+ #addBuilder(builder) {
19
+ const latestCategory = this.categories[this.categories.length - 1];
20
+ if (latestCategory) {
21
+ latestCategory.addOptions(builder);
22
+ } else {
23
+ throw new Error("You need to create a category before adding options");
24
+ }
25
+ }
26
+
27
+ setGetMethod(method) {
28
+ if (typeof method !== "function") throw new TypeError("Get method must be a function");
29
+ this.getMethod = method;
30
+ return this;
31
+ }
32
+
33
+ setSaveMethod(method) {
34
+ if (typeof method !== "function") throw new TypeError("Save method must be a function");
35
+ this.saveMethod = method;
36
+ return this;
37
+ }
38
+
39
+ setPermissions(permissions) {
40
+ if (!Array.isArray(permissions)) throw new TypeError("Permissions must be an array");
41
+ this.permissions = permissions.map(entry => {
42
+ if (Array.isArray(entry)) {
43
+ return entry;
44
+ }
45
+ return [entry];
46
+ });
47
+ return this;
48
+ }
49
+
50
+ createCategory(callback) {
51
+ const builder = new CategoryBuilder();
52
+ callback(builder);
53
+ this.categories.push(builder);
54
+ return this;
55
+ }
56
+
57
+ addString(callback) {
58
+ const builder = new StringBuilder();
59
+ callback(builder);
60
+ this.#addBuilder(builder);
61
+ return this;
62
+ }
63
+
64
+ addNumber(callback) {
65
+ const builder = new NumberBuilder();
66
+ callback(builder);
67
+ this.#addBuilder(builder);
68
+ return this;
69
+ }
70
+
71
+ addBoolean(callback) {
72
+ const builder = new BooleanBuilder();
73
+ callback(builder);
74
+ this.#addBuilder(builder);
75
+ return this;
76
+ }
77
+
78
+ addStringList(callback) {
79
+ const builder = new StringListBuilder();
80
+ callback(builder);
81
+ this.#addBuilder(builder);
82
+ return this;
83
+ }
84
+
85
+ addNumberList(callback) {
86
+ const builder = new NumberListBuilder();
87
+ callback(builder);
88
+ this.#addBuilder(builder);
89
+ return this;
90
+ }
91
+
92
+ addObject(callback) {
93
+ const builder = new ObjectBuilder();
94
+ callback(builder);
95
+ this.#addBuilder(builder);
96
+ return this;
97
+ }
98
+
99
+ addObjectList(callback) {
100
+ const builder = new ObjectListBuilder();
101
+ callback(builder);
102
+ this.#addBuilder(builder);
103
+ return this;
104
+ }
105
+
106
+ addStringSelect(callback) {
107
+ const builder = new StringSelectBuilder();
108
+ callback(builder);
109
+ this.#addBuilder(builder);
110
+ return this;
111
+ }
112
+
113
+ addUserSelect(callback) {
114
+ const builder = new UserSelectBuilder();
115
+ callback(builder);
116
+ this.#addBuilder(builder);
117
+ return this;
118
+ }
119
+
120
+ addRoleSelect(callback) {
121
+ const builder = new RoleSelectBuilder();
122
+ callback(builder);
123
+ this.#addBuilder(builder);
124
+ return this;
125
+ }
126
+
127
+ addMentionableSelect(callback) {
128
+ const builder = new MentionableSelectBuilder();
129
+ callback(builder);
130
+ this.#addBuilder(builder);
131
+ return this;
132
+ }
133
+
134
+ addChannelSelect(callback) {
135
+ const builder = new ChannelSelectBuilder();
136
+ callback(builder);
137
+ this.#addBuilder(builder);
138
+ return this;
139
+ }
140
+
141
+ build() {
142
+ const base = super.build();
143
+ let permissions;
144
+ if (this.permissions) {
145
+ permissions = this.permissions;
146
+ } else {
147
+ permissions = [];
148
+ }
149
+ return {
150
+ ...base,
151
+ permissions,
152
+ categories: this.categories.map(category => category.build()),
153
+ };
154
+ }
155
+ }
156
+
157
+ module.exports = { MenuBuilder };
@@ -0,0 +1,334 @@
1
+ const { renderNavigation, renderContainer, renderOption } = require("./render/page");
2
+ const { renderModal, renderTextInput } = require("./render/modal");
3
+ const { renderListPage, renderListTextInput, renderObjectListItemPage, renderObjectListItemTextInput } = require("./render/list");
4
+ const { renderObjectPage, renderObjectTextInput, renderObjectListPage, renderObjectListTextInput } = require("./render/object");
5
+ const { OptionTypes } = require("./utils/types");
6
+ const { PageRenderOverhead, OptionRenderCost, MaxComponents, ComponentsV2MessageFlags } = require("./utils/constants");
7
+ const { validateComponents } = require("./utils/componentsValidate");
8
+ const { arrayClone, normalizeObjectListItems, objectListItemsNeedReshape } = require("./utils/helpers");
9
+
10
+ class MenuInstance {
11
+ constructor(builder, options = {}) {
12
+ this.key = builder.key;
13
+ this.title = builder.title;
14
+ this.pages = [];
15
+ this.permissions = builder.permissions ?? [];
16
+
17
+ if (builder.getMethod && !options.get) {
18
+ options.get = builder.getMethod;
19
+ builder.getMethod = null;
20
+ }
21
+ if (builder.saveMethod && !options.save) {
22
+ options.save = builder.saveMethod;
23
+ builder.saveMethod = null;
24
+ }
25
+ if (!options.get) {
26
+ throw new Error("You must provide a get function in the options");
27
+ }
28
+ if (typeof options.get !== "function") {
29
+ throw new TypeError("get must be a function");
30
+ }
31
+ if (!options.save) {
32
+ throw new Error("You must provide a save function in the options");
33
+ }
34
+ if (typeof options.save !== "function") {
35
+ throw new TypeError("save must be a function");
36
+ }
37
+
38
+ this.getData = options.get;
39
+ this.saveData = options.save;
40
+ this.builder = builder;
41
+ }
42
+
43
+ async get(key, ctx = {}) {
44
+ return this.getData(key, ctx);
45
+ }
46
+
47
+ async save(key, value, ctx = {}) {
48
+ return this.saveData(key, value, ctx);
49
+ }
50
+
51
+ ensureBuilt() {
52
+ if (this.pages.length === 0) {
53
+ this.build();
54
+ }
55
+ }
56
+
57
+ build() {
58
+ const built = this.builder.build();
59
+ this.pages = [];
60
+
61
+ for (const category of built.categories) {
62
+ const categoryPages = [];
63
+ let currentCost = 0;
64
+
65
+ const newPage = () => {
66
+ currentCost = PageRenderOverhead;
67
+ return { title: built.title, description: category.title, options: [] };
68
+ };
69
+
70
+ let currentPage = newPage();
71
+
72
+ for (const option of category.options) {
73
+ if (currentCost + OptionRenderCost > MaxComponents) {
74
+ categoryPages.push(currentPage);
75
+ currentPage = newPage();
76
+ }
77
+ currentPage.options.push(option);
78
+ currentCost += OptionRenderCost;
79
+ }
80
+
81
+ if (currentPage.options.length > 0) {
82
+ categoryPages.push(currentPage);
83
+ }
84
+ if (categoryPages.length > 0) {
85
+ this.pages.push(categoryPages);
86
+ }
87
+ }
88
+
89
+ for (const categoryPages of this.pages) {
90
+ for (const page of categoryPages) {
91
+ if (page.options) {
92
+ validateComponents(page.options);
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ getOption(category, index, optionKey) {
99
+ const categoryPages = this.pages[category];
100
+ if (!categoryPages) {
101
+ return null;
102
+ }
103
+ const page = categoryPages[index];
104
+ if (!page) {
105
+ return null;
106
+ }
107
+ return page.options.find(o => o.key === optionKey) ?? null;
108
+ }
109
+
110
+ getNavigationTargets(category, index) {
111
+ const categoryPages = this.pages[category];
112
+ const hasPrevCategory = category > 0;
113
+ const hasNextCategory = category < this.pages.length - 1;
114
+ const hasPrevPage = index > 0;
115
+ const hasNextPage = index < categoryPages.length - 1;
116
+
117
+ let previousCategory = null;
118
+ if (hasPrevCategory) {
119
+ previousCategory = { cat: category - 1, page: 0 };
120
+ }
121
+ let nextCategory = null;
122
+ if (hasNextCategory) {
123
+ nextCategory = { cat: category + 1, page: 0 };
124
+ }
125
+
126
+ let previousPage = null;
127
+ if (hasPrevPage) {
128
+ previousPage = { cat: category, page: index - 1 };
129
+ } else if (hasPrevCategory) {
130
+ previousPage = { cat: category - 1, page: this.pages[category - 1].length - 1 };
131
+ }
132
+
133
+ let nextPage = null;
134
+ if (hasNextPage) {
135
+ nextPage = { cat: category, page: index + 1 };
136
+ } else if (hasNextCategory) {
137
+ nextPage = { cat: category + 1, page: 0 };
138
+ }
139
+
140
+ return { previousCategory, nextCategory, previousPage, nextPage };
141
+ }
142
+
143
+ async renderPage({ category = 0, index = 0, userId, guildId } = {}) {
144
+ this.ensureBuilt();
145
+
146
+ if (category < 0 || category >= this.pages.length) {
147
+ throw new RangeError(`Category ${category} out of range (0-${this.pages.length - 1})`);
148
+ }
149
+ const categoryPages = this.pages[category];
150
+ if (index < 0 || index >= categoryPages.length) {
151
+ throw new RangeError(`Page ${index} out of range in category ${category} (0-${categoryPages.length - 1})`);
152
+ }
153
+
154
+ const page = categoryPages[index];
155
+ const ctx = { guildId, userId };
156
+ const navigation = renderNavigation(this.key, userId, this.getNavigationTargets(category, index), category, index);
157
+ const optionComponents = [];
158
+ for (const option of page.options) {
159
+ const value = await this.get(option.key, ctx);
160
+ optionComponents.push(...renderOption(this.key, option, value, userId, category, index));
161
+ }
162
+ const container = renderContainer({
163
+ title: page.title,
164
+ description: page.description,
165
+ components: optionComponents,
166
+ });
167
+ return {
168
+ flags: ComponentsV2MessageFlags,
169
+ components: [container, navigation],
170
+ };
171
+ }
172
+
173
+ async renderModal(optionKey, { category = 0, index = 0, guildId, userId } = {}) {
174
+ this.ensureBuilt();
175
+ const categoryPages = this.pages[category];
176
+ if (!categoryPages) {
177
+ throw new RangeError(`Category ${category} does not exist`);
178
+ }
179
+ const page = categoryPages[index];
180
+ if (!page) {
181
+ throw new RangeError(`Page ${index} does not exist in category ${category}`);
182
+ }
183
+ const option = page.options.find(o => o.key === optionKey);
184
+ if (!option) {
185
+ throw new Error(`Option "${optionKey}" not found on page ${category}:${index}`);
186
+ }
187
+ const currentValue = await this.get(option.key, { guildId, userId });
188
+ return renderModal(this.key, this.title, [renderTextInput(option, currentValue)], { category, page: index });
189
+ }
190
+
191
+ async renderListPage(optionKey, listPage, { category = 0, index = 0, userId, guildId } = {}) {
192
+ this.ensureBuilt();
193
+ const option = this.getOption(category, index, optionKey);
194
+ if (!option) {
195
+ throw new Error(`Option "${optionKey}" not found on page ${category}:${index}`);
196
+ }
197
+ let items = await this.get(option.key, { guildId, userId }) ?? [];
198
+ if (option.itemType === OptionTypes.Object) {
199
+ const props = option.itemConfig.properties;
200
+ if (objectListItemsNeedReshape(props, items)) {
201
+ items = normalizeObjectListItems(props, arrayClone(items));
202
+ await this.save(option.key, items, { guildId, userId });
203
+ }
204
+ }
205
+ return renderListPage(this.key, option, items, listPage, userId, category, index);
206
+ }
207
+
208
+ async renderListModal(optionKey, itemIndex, { category = 0, index = 0, sub = 0, guildId, userId } = {}) {
209
+ this.ensureBuilt();
210
+ const option = this.getOption(category, index, optionKey);
211
+ if (!option) {
212
+ throw new Error(`Option "${optionKey}" not found on page ${category}:${index}`);
213
+ }
214
+ const items = await this.get(option.key, { guildId, userId }) ?? [];
215
+ const currentValue = itemIndex != null ? items[itemIndex] : null;
216
+ return renderModal(
217
+ this.key,
218
+ this.title,
219
+ [renderListTextInput(option, currentValue, itemIndex)],
220
+ { category, page: index, sub, item: itemIndex ?? -1 },
221
+ );
222
+ }
223
+
224
+ async renderObjectPage(objectKey, { category = 0, index = 0, userId, guildId, objectPage = 0 } = {}) {
225
+ this.ensureBuilt();
226
+ const option = this.getOption(category, index, objectKey);
227
+ if (!option) {
228
+ throw new Error(`Object "${objectKey}" not found on page ${category}:${index}`);
229
+ }
230
+ const objectValue = await this.get(option.key, { guildId, userId }) ?? {};
231
+ return renderObjectPage(this.key, option, objectValue, userId, category, index, objectPage);
232
+ }
233
+
234
+ async renderObjectModal(objectKey, subKey, { category = 0, index = 0, guildId, userId, objectPage = 0 } = {}) {
235
+ this.ensureBuilt();
236
+ const option = this.getOption(category, index, objectKey);
237
+ if (!option) {
238
+ throw new Error(`Object "${objectKey}" not found on page ${category}:${index}`);
239
+ }
240
+ const subOption = option.options.find(o => o.key === subKey);
241
+ if (!subOption) {
242
+ throw new Error(`Sub-option "${subKey}" not found in Object "${objectKey}"`);
243
+ }
244
+ const objectValue = await this.get(objectKey, { guildId, userId }) ?? {};
245
+ const currentValue = objectValue[subKey] != null ? objectValue[subKey] : null;
246
+ return renderModal(
247
+ this.key,
248
+ this.title,
249
+ [renderObjectTextInput(objectKey, subOption, currentValue)],
250
+ { category, page: index, op: objectPage },
251
+ );
252
+ }
253
+
254
+ async renderObjectListPage(objectKey, subKey, listPage, { category = 0, index = 0, userId, guildId, objectPage = 0 } = {}) {
255
+ this.ensureBuilt();
256
+ const option = this.getOption(category, index, objectKey);
257
+ if (!option) {
258
+ throw new Error(`Object "${objectKey}" not found on page ${category}:${index}`);
259
+ }
260
+ const subOption = option.options.find(o => o.key === subKey);
261
+ if (!subOption) {
262
+ throw new Error(`Sub-option "${subKey}" not found in Object "${objectKey}"`);
263
+ }
264
+ const objectValue = await this.get(objectKey, { guildId, userId }) ?? {};
265
+ const items = Array.isArray(objectValue[subKey]) ? objectValue[subKey] : [];
266
+ return renderObjectListPage(this.key, option, subOption, items, listPage, userId, category, index, objectPage);
267
+ }
268
+
269
+ async renderListObjectItemPage(optionKey, itemIndex, { category = 0, index = 0, userId, guildId, listPage = 0 } = {}) {
270
+ this.ensureBuilt();
271
+ const option = this.getOption(category, index, optionKey);
272
+ if (!option) {
273
+ throw new Error(`Option "${optionKey}" not found on page ${category}:${index}`);
274
+ }
275
+ let items = await this.get(option.key, { guildId, userId }) ?? [];
276
+ if (option.itemType === OptionTypes.Object) {
277
+ const props = option.itemConfig.properties;
278
+ if (objectListItemsNeedReshape(props, items)) {
279
+ items = normalizeObjectListItems(props, arrayClone(items));
280
+ await this.save(option.key, items, { guildId, userId });
281
+ }
282
+ }
283
+ const safeIndex = items.length <= 0 ? 0 : Math.max(0, Math.min(itemIndex, items.length - 1));
284
+ const currentItem = items[safeIndex] ?? null;
285
+ return renderObjectListItemPage(this.key, option, safeIndex, currentItem, userId, category, index, listPage, items.length);
286
+ }
287
+
288
+ async renderListObjectItemModal(optionKey, propertyKey, itemIndex, { category = 0, index = 0, sub = 0, guildId, userId } = {}) {
289
+ this.ensureBuilt();
290
+ const option = this.getOption(category, index, optionKey);
291
+ if (!option) {
292
+ throw new Error(`Option "${optionKey}" not found on page ${category}:${index}`);
293
+ }
294
+ const property = option.itemConfig.properties.find(p => p.key === propertyKey);
295
+ if (!property) {
296
+ throw new Error(`Property "${propertyKey}" not found in ObjectList "${optionKey}"`);
297
+ }
298
+ const items = await this.get(option.key, { guildId, userId }) ?? [];
299
+ const currentItem = items[itemIndex] ?? null;
300
+ const currentValue =
301
+ currentItem != null && typeof currentItem === "object"
302
+ ? currentItem[propertyKey] ?? null
303
+ : null;
304
+ return renderModal(
305
+ this.key,
306
+ this.title,
307
+ [renderObjectListItemTextInput(option, property, currentValue)],
308
+ { category, page: index, sub, item: itemIndex },
309
+ );
310
+ }
311
+
312
+ async renderObjectListModal(objectKey, subKey, itemIndex, { category = 0, index = 0, sub = 0, guildId, userId, objectPage = 0 } = {}) {
313
+ this.ensureBuilt();
314
+ const option = this.getOption(category, index, objectKey);
315
+ if (!option) {
316
+ throw new Error(`Object "${objectKey}" not found on page ${category}:${index}`);
317
+ }
318
+ const subOption = option.options.find(o => o.key === subKey);
319
+ if (!subOption) {
320
+ throw new Error(`Sub-option "${subKey}" not found in Object "${objectKey}"`);
321
+ }
322
+ const objectValue = await this.get(objectKey, { guildId, userId }) ?? {};
323
+ const items = Array.isArray(objectValue[subKey]) ? objectValue[subKey] : [];
324
+ const currentValue = itemIndex != null ? items[itemIndex] : null;
325
+ return renderModal(
326
+ this.key,
327
+ this.title,
328
+ [renderObjectListTextInput(objectKey, subOption, currentValue, itemIndex)],
329
+ { category, page: index, sub, item: itemIndex ?? -1, op: objectPage },
330
+ );
331
+ }
332
+ }
333
+
334
+ module.exports = { MenuInstance };
@@ -0,0 +1,83 @@
1
+ const { MenuInstance } = require("./menuInstance");
2
+ const { decodeId } = require("./utils/customIds");
3
+ const { EphemeralMessageFlag } = require("./utils/constants");
4
+ const { InteractionTypes, ResponseTypes } = require("./utils/types");
5
+ const { handleComponent } = require("./handle/component");
6
+ const { handleModal } = require("./handle/modal");
7
+ const { normalizeInteraction } = require("./utils/adapters");
8
+
9
+ class MenuManager {
10
+ constructor() {
11
+ this.menus = new Map();
12
+ }
13
+
14
+ async registerMenu(menu, options = {}) {
15
+ if (this.menus.has(menu.key)) {
16
+ throw new Error(`Menu with key ${menu.key} already exists`);
17
+ }
18
+ const instance = new MenuInstance(menu, options);
19
+ try {
20
+ instance.build();
21
+ } catch (e) {
22
+ throw new Error(`Failed to build menu with key ${menu.key}: ${e.message}`);
23
+ }
24
+ this.menus.set(menu.key, instance);
25
+ }
26
+
27
+ async renderMenu(key, { category = 0, index = 0, userId, guildId } = {}) {
28
+ const menu = this.menus.get(key);
29
+ if (!menu) {
30
+ throw new Error(`Menu with key ${key} does not exist`);
31
+ }
32
+ return menu.renderPage({ category, index, userId, guildId });
33
+ }
34
+
35
+ menuFromCustomId(customId) {
36
+ const { menu_key } = decodeId(customId);
37
+ const menu = this.menus.get(menu_key);
38
+ if (!menu) {
39
+ throw new Error(`Menu with key ${menu_key} does not exist`);
40
+ }
41
+ return { menu, menu_key };
42
+ }
43
+
44
+ async handleComponent(interaction) {
45
+ const normalized = normalizeInteraction(interaction);
46
+ const customId = normalized?.data?.custom_id;
47
+ if (!customId) {
48
+ throw new Error("Invalid interaction payload: missing custom_id");
49
+ }
50
+ const { menu, menu_key } = this.menuFromCustomId(customId);
51
+ return handleComponent(normalized, menu, menu_key, this.renderMenu.bind(this));
52
+ }
53
+
54
+ async handleModal(interaction) {
55
+ const normalized = normalizeInteraction(interaction);
56
+ const customId = normalized?.data?.custom_id;
57
+ if (!customId) {
58
+ throw new Error("Invalid interaction payload: missing custom_id");
59
+ }
60
+ const { menu, menu_key } = this.menuFromCustomId(customId);
61
+ return handleModal(normalized, menu, menu_key, this.renderMenu.bind(this));
62
+ }
63
+
64
+ async handleInteraction(interaction) {
65
+ try {
66
+ const normalized = normalizeInteraction(interaction);
67
+ if (normalized.type === InteractionTypes.MessageComponent) {
68
+ return await this.handleComponent(normalized);
69
+ }
70
+ if (normalized.type === InteractionTypes.ModalSubmit) {
71
+ return await this.handleModal(normalized);
72
+ }
73
+ throw new Error(`Unknown interaction type "${normalized.type}"`);
74
+ } catch (e) {
75
+ return {
76
+ type: ResponseTypes.ChannelMessageWithSource,
77
+ data: { flags: EphemeralMessageFlag, content: `Error: ${e.message}` },
78
+ };
79
+ }
80
+ }
81
+ }
82
+
83
+ module.exports = { MenuManager };
@@ -0,0 +1,38 @@
1
+ const { Builder } = require("../builder");
2
+ const { OptionTypes } = require("../utils/types");
3
+
4
+ class BooleanBuilder extends Builder {
5
+ constructor() {
6
+ super(OptionTypes.Boolean);
7
+ this.preview = false;
8
+ this.trueLabel = "True";
9
+ this.falseLabel = "False";
10
+ }
11
+
12
+ setTrueLabel(label) {
13
+ label = String(label);
14
+ if (typeof label !== "string") throw new TypeError("True label must be a string");
15
+ this.trueLabel = label;
16
+ return this;
17
+ }
18
+
19
+ setFalseLabel(label) {
20
+ label = String(label);
21
+ if (typeof label !== "string") throw new TypeError("False label must be a string");
22
+ this.falseLabel = label;
23
+ return this;
24
+ }
25
+
26
+ build() {
27
+ const base = super.build();
28
+ return {
29
+ ...base,
30
+ trueLabel: this.trueLabel,
31
+ falseLabel: this.falseLabel,
32
+ };
33
+ }
34
+ }
35
+
36
+ module.exports = {
37
+ BooleanBuilder
38
+ }
@@ -0,0 +1,27 @@
1
+ const { Builder } = require("../builder");
2
+ const { OptionTypes } = require("../utils/types");
3
+
4
+ class CategoryBuilder extends Builder {
5
+ constructor() {
6
+ super(OptionTypes.Category);
7
+ this.options = [];
8
+ }
9
+
10
+ addOptions(...options) {
11
+ for (const option of options) {
12
+ this.options.push(option);
13
+ }
14
+ }
15
+
16
+ build() {
17
+ const base = super.build();
18
+ return {
19
+ ...base,
20
+ options: this.options.map(option => option.build())
21
+ }
22
+ }
23
+ }
24
+
25
+ module.exports = {
26
+ CategoryBuilder
27
+ }