@kevlid/discordmenus 0.1.2 → 0.1.4
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/index.d.ts +5 -0
- package/package.json +1 -1
- package/src/handle/component.js +25 -4
- package/src/index.js +4 -1
- package/src/menuInstance.js +17 -2
- package/src/render/selectMenu.js +83 -1
- package/src/utils/adapters.js +9 -1
- package/src/utils/formatKey.js +46 -1
package/index.d.ts
CHANGED
|
@@ -257,3 +257,8 @@ export declare function normalizeInteraction(
|
|
|
257
257
|
): DiscordInteraction;
|
|
258
258
|
export declare function fromEris(interaction: DiscordInteraction): DiscordInteraction;
|
|
259
259
|
export declare function fromDiscordJS(interaction: DiscordInteraction): DiscordInteraction;
|
|
260
|
+
|
|
261
|
+
/** Encode option/menu keys for custom IDs (uppercase → _lowercase, spaces → _). */
|
|
262
|
+
export declare function formatKey(key: string): string;
|
|
263
|
+
/** Restore original key before get/save callbacks (inverse of formatKey). */
|
|
264
|
+
export declare function decodeKey(encoded: string): string;
|
package/package.json
CHANGED
package/src/handle/component.js
CHANGED
|
@@ -312,8 +312,8 @@ async function handleComponent(interaction, menu, menu_key, renderMenu) {
|
|
|
312
312
|
}
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
-
|
|
316
|
-
|
|
315
|
+
let category = parseInt(options.get("cat") ?? 0);
|
|
316
|
+
let page = parseInt(options.get("page") ?? 0);
|
|
317
317
|
const action = values[1];
|
|
318
318
|
|
|
319
319
|
let result;
|
|
@@ -362,10 +362,31 @@ async function handleComponent(interaction, menu, menu_key, renderMenu) {
|
|
|
362
362
|
) {
|
|
363
363
|
const optionKey = values[0];
|
|
364
364
|
const selected = Array.isArray(interactionData.values) ? interactionData.values : [];
|
|
365
|
-
|
|
365
|
+
let opt = menu.getOption(category, page, optionKey);
|
|
366
|
+
if (!opt) {
|
|
367
|
+
const found = menu.findOption(optionKey);
|
|
368
|
+
if (found) {
|
|
369
|
+
opt = found.option;
|
|
370
|
+
category = found.category;
|
|
371
|
+
page = found.page;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
366
374
|
const maxSel = Number(opt?.maxValues);
|
|
367
375
|
const multi = Number.isFinite(maxSel) && maxSel > 1;
|
|
368
|
-
|
|
376
|
+
let saveValue = multi ? [...selected] : selected[0] ?? null;
|
|
377
|
+
if (
|
|
378
|
+
!multi &&
|
|
379
|
+
saveValue == null &&
|
|
380
|
+
opt?.type === OptionTypes.StringSelect &&
|
|
381
|
+
Number(opt.minValues ?? 1) >= 1 &&
|
|
382
|
+
Array.isArray(opt.choices) &&
|
|
383
|
+
opt.choices.length === 1
|
|
384
|
+
) {
|
|
385
|
+
const only = opt.choices[0]?.value;
|
|
386
|
+
if (only != null && only !== "") {
|
|
387
|
+
saveValue = only;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
369
390
|
await menu.save(optionKey, saveValue, { guildId, userId });
|
|
370
391
|
result = updateMessage(
|
|
371
392
|
await renderMenu(menu_key, { category, index: page, userId, guildId }),
|
package/src/index.js
CHANGED
|
@@ -4,6 +4,7 @@ const { OptionTypes } = require("./utils/types");
|
|
|
4
4
|
const { CustomIdPrefix, EphemeralMessageFlag } = require("./utils/constants");
|
|
5
5
|
const { MenuStorage, MemoryMenuStorage } = require("./utils/storage");
|
|
6
6
|
const { normalizeInteraction, fromEris, fromDiscordJS } = require("./utils/adapters");
|
|
7
|
+
const { formatKey, decodeKey } = require("./utils/formatKey");
|
|
7
8
|
|
|
8
9
|
module.exports = {
|
|
9
10
|
MenuManager,
|
|
@@ -16,4 +17,6 @@ module.exports = {
|
|
|
16
17
|
normalizeInteraction,
|
|
17
18
|
fromEris,
|
|
18
19
|
fromDiscordJS,
|
|
19
|
-
|
|
20
|
+
formatKey,
|
|
21
|
+
decodeKey,
|
|
22
|
+
};
|
package/src/menuInstance.js
CHANGED
|
@@ -6,6 +6,7 @@ const { OptionTypes } = require("./utils/types");
|
|
|
6
6
|
const { PageRenderOverhead, OptionRenderCost, MaxComponents, ComponentsV2MessageFlags } = require("./utils/constants");
|
|
7
7
|
const { validateComponents } = require("./utils/componentsValidate");
|
|
8
8
|
const { arrayClone, normalizeObjectListItems, objectListItemsNeedReshape } = require("./utils/helpers");
|
|
9
|
+
const { decodeKey } = require("./utils/formatKey");
|
|
9
10
|
|
|
10
11
|
class MenuInstance {
|
|
11
12
|
constructor(builder, options = {}) {
|
|
@@ -41,11 +42,11 @@ class MenuInstance {
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
async get(key, ctx = {}) {
|
|
44
|
-
return this.getData(key, ctx);
|
|
45
|
+
return this.getData(decodeKey(key), ctx);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
async save(key, value, ctx = {}) {
|
|
48
|
-
return this.saveData(key, value, ctx);
|
|
49
|
+
return this.saveData(decodeKey(key), value, ctx);
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
ensureBuilt() {
|
|
@@ -107,6 +108,20 @@ class MenuInstance {
|
|
|
107
108
|
return page.options.find(o => o.key === optionKey) ?? null;
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
findOption(optionKey) {
|
|
112
|
+
for (let cat = 0; cat < this.pages.length; cat++) {
|
|
113
|
+
const categoryPages = this.pages[cat];
|
|
114
|
+
for (let page = 0; page < categoryPages.length; page++) {
|
|
115
|
+
const p = categoryPages[page];
|
|
116
|
+
const opt = p.options?.find(o => o.key === optionKey) ?? null;
|
|
117
|
+
if (opt) {
|
|
118
|
+
return { option: opt, category: cat, page };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
110
125
|
getNavigationTargets(category, index) {
|
|
111
126
|
const categoryPages = this.pages[category];
|
|
112
127
|
const hasPrevCategory = category > 0;
|
package/src/render/selectMenu.js
CHANGED
|
@@ -1 +1,83 @@
|
|
|
1
|
-
const { OptionTypes } = require("../utils/types");
|
|
2
1
|
if (Array.isArray(currentValue)) {
|
|
3
2
|
return currentValue;
|
|
4
3
|
}
|
|
5
4
|
if (currentValue != null) {
|
|
6
5
|
return [currentValue];
|
|
7
6
|
}
|
|
8
7
|
return [];
|
|
9
8
|
const ctx = { userId, category, page };
|
|
10
9
|
const customId = encodeId(menuKey, [opt.key, opt.type], ctx);
|
|
11
10
|
const component = {
|
|
12
11
|
type: opt.selectType,
|
|
13
12
|
custom_id: customId,
|
|
14
13
|
min_values: opt.minValues,
|
|
15
14
|
max_values: opt.maxValues,
|
|
16
15
|
};
|
|
17
16
|
if (opt.placeholder != null) {
|
|
18
17
|
component.placeholder = opt.placeholder;
|
|
19
18
|
}
|
|
20
19
|
if (opt.type === OptionTypes.StringSelect) {
|
|
21
20
|
const selectedValues = toIdArray(currentValue);
|
|
22
21
|
component.options = opt.choices.map(c => ({
|
|
23
22
|
label: c.label,
|
|
24
23
|
value: c.value,
|
|
25
24
|
description: c.description,
|
|
26
25
|
default: selectedValues.includes(c.value),
|
|
27
26
|
}));
|
|
28
27
|
}
|
|
29
28
|
if (opt.type === OptionTypes.UserSelect) {
|
|
30
29
|
const ids = toIdArray(currentValue);
|
|
31
30
|
if (ids.length > 0) {
|
|
32
31
|
component.default_values = ids.map(id => ({ id, type: "user" }));
|
|
33
32
|
}
|
|
34
33
|
}
|
|
35
34
|
if (opt.type === OptionTypes.RoleSelect) {
|
|
36
35
|
const ids = toIdArray(currentValue);
|
|
37
36
|
if (ids.length > 0) {
|
|
38
37
|
component.default_values = ids.map(id => ({ id, type: "role" }));
|
|
39
38
|
}
|
|
40
39
|
}
|
|
41
40
|
if (opt.type === OptionTypes.ChannelSelect) {
|
|
42
41
|
if (opt.channelTypes && opt.channelTypes.length > 0) {
|
|
43
42
|
component.channel_types = opt.channelTypes;
|
|
44
43
|
}
|
|
45
44
|
const ids = toIdArray(currentValue);
|
|
46
45
|
if (ids.length > 0) {
|
|
47
46
|
component.default_values = ids.map(id => ({ id, type: "channel" }));
|
|
48
47
|
}
|
|
49
48
|
}
|
|
50
49
|
return component;
|
|
51
50
|
const lines = [`**${opt.title}**`];
|
|
52
51
|
if (opt.description) {
|
|
53
52
|
lines.push(`-# ${opt.description}`);
|
|
54
53
|
}
|
|
55
54
|
const textDisplay = renderTextDisplay(lines.join("\n"));
|
|
56
55
|
const selectMenu = renderSelectMenuComponent(menuKey, opt, currentValue, userId, category, page);
|
|
57
56
|
const actionRow = renderActionRow(selectMenu);
|
|
58
57
|
return [textDisplay, actionRow, renderSeparator()];
|
|
58
|
+
const { OptionTypes } = require("../utils/types");
|
|
59
|
+
const { renderTextDisplay, renderActionRow, renderSeparator } = require("./utils");
|
|
60
|
+
const { encodeId } = require("../utils/customIds");
|
|
61
|
+
|
|
62
|
+
function toIdArray(currentValue) {
|
|
63
|
+
if (Array.isArray(currentValue)) {
|
|
64
|
+
return currentValue;
|
|
65
|
+
}
|
|
66
|
+
if (currentValue != null) {
|
|
67
|
+
return [currentValue];
|
|
68
|
+
}
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderSelectMenuComponent(menuKey, opt, currentValue, userId, category, page) {
|
|
73
|
+
// Stateless: do not depend on current rendered page in the custom id.
|
|
74
|
+
// The handler can locate the option by key in the built menu.
|
|
75
|
+
const ctx = { u: userId };
|
|
76
|
+
const customId = encodeId(menuKey, [opt.key, opt.type], ctx);
|
|
77
|
+
|
|
78
|
+
const component = {
|
|
79
|
+
type: opt.selectType,
|
|
80
|
+
custom_id: customId,
|
|
81
|
+
min_values: opt.minValues,
|
|
82
|
+
max_values: opt.maxValues,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (opt.placeholder != null) {
|
|
86
|
+
component.placeholder = opt.placeholder;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (opt.type === OptionTypes.StringSelect) {
|
|
90
|
+
const selectedValues = toIdArray(currentValue);
|
|
91
|
+
component.options = opt.choices.map(c => ({
|
|
92
|
+
label: c.label,
|
|
93
|
+
value: c.value,
|
|
94
|
+
description: c.description,
|
|
95
|
+
default: selectedValues.includes(c.value),
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (opt.type === OptionTypes.UserSelect) {
|
|
100
|
+
const ids = toIdArray(currentValue);
|
|
101
|
+
if (ids.length > 0) {
|
|
102
|
+
component.default_values = ids.map(id => ({ id, type: "user" }));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (opt.type === OptionTypes.RoleSelect) {
|
|
107
|
+
const ids = toIdArray(currentValue);
|
|
108
|
+
if (ids.length > 0) {
|
|
109
|
+
component.default_values = ids.map(id => ({ id, type: "role" }));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (opt.type === OptionTypes.ChannelSelect) {
|
|
114
|
+
if (opt.channelTypes && opt.channelTypes.length > 0) {
|
|
115
|
+
component.channel_types = opt.channelTypes;
|
|
116
|
+
}
|
|
117
|
+
const ids = toIdArray(currentValue);
|
|
118
|
+
if (ids.length > 0) {
|
|
119
|
+
component.default_values = ids.map(id => ({ id, type: "channel" }));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return component;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function renderSelectMenuOption(menuKey, opt, currentValue, userId, category, page) {
|
|
127
|
+
const lines = [`**${opt.title}**`];
|
|
128
|
+
|
|
129
|
+
if (opt.description) {
|
|
130
|
+
lines.push(`-# ${opt.description}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const textDisplay = renderTextDisplay(lines.join("\n"));
|
|
134
|
+
const selectMenu = renderSelectMenuComponent(menuKey, opt, currentValue, userId, category, page);
|
|
135
|
+
const actionRow = renderActionRow(selectMenu);
|
|
136
|
+
|
|
137
|
+
return [textDisplay, actionRow, renderSeparator()];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { renderSelectMenuOption };
|
package/src/utils/adapters.js
CHANGED
|
@@ -37,7 +37,15 @@ function normalizeInteraction(interaction) {
|
|
|
37
37
|
customId = source.id;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
// Prefer live interaction: toJSON() often omits or strips data.values for select menus,
|
|
41
|
+
// which breaks saves (especially single-value selects).
|
|
42
|
+
let values = interaction?.data?.values;
|
|
43
|
+
if (!Array.isArray(values) && interaction != null && typeof interaction.values !== "undefined") {
|
|
44
|
+
values = interaction.values;
|
|
45
|
+
}
|
|
46
|
+
if (!Array.isArray(values)) {
|
|
47
|
+
values = source.data?.values ?? source.values ?? [];
|
|
48
|
+
}
|
|
41
49
|
if (!Array.isArray(values)) {
|
|
42
50
|
values = [];
|
|
43
51
|
}
|
package/src/utils/formatKey.js
CHANGED
|
@@ -1 +1,46 @@
|
|
|
1
|
-
function formatKey(key) {
|
|
2
1
|
if (typeof key !== "string") throw new TypeError("Key must be a string");
|
|
3
2
|
return key.toLowerCase().replace(" ", "_");
|
|
4
3
|
formatKey
|
|
4
|
+
/**
|
|
5
|
+
* Encodes a key for custom IDs (Discord is case-insensitive in some paths; this keeps uniqueness).
|
|
6
|
+
* Each uppercase letter becomes "_" + its lowercase form (e.g. guildId → guild_id).
|
|
7
|
+
* Spaces become "_".
|
|
8
|
+
* Lowercase letters, digits, and underscores are kept as-is (underscores are not escaped).
|
|
9
|
+
*/
|
|
10
|
+
function formatKey(key) {
|
|
11
|
+
if (typeof key !== "string") throw new TypeError("Key must be a string");
|
|
12
|
+
const s = String(key).replace(/ /g, "_");
|
|
13
|
+
let out = "";
|
|
14
|
+
for (const c of s) {
|
|
15
|
+
if (c >= "A" && c <= "Z") {
|
|
16
|
+
out += "_" + c.toLowerCase();
|
|
17
|
+
} else {
|
|
18
|
+
out += c;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Reverses formatKey: "_" followed by a-z becomes A–Z. Literal "_" + letter in keys that were
|
|
26
|
+
* not produced by an uppercase letter may decode incorrectly; prefer camelCase/PascalCase keys.
|
|
27
|
+
*/
|
|
28
|
+
function decodeKey(encoded) {
|
|
29
|
+
if (typeof encoded !== "string") throw new TypeError("Key must be a string");
|
|
30
|
+
let out = "";
|
|
31
|
+
for (let i = 0; i < encoded.length; i++) {
|
|
32
|
+
const c = encoded[i];
|
|
33
|
+
if (c === "_" && i + 1 < encoded.length) {
|
|
34
|
+
const next = encoded[i + 1];
|
|
35
|
+
if (next >= "a" && next <= "z") {
|
|
36
|
+
out += next.toUpperCase();
|
|
37
|
+
i++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
out += c;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = {
|
|
47
|
+
formatKey,
|
|
48
|
+
decodeKey,
|
|
49
|
+
};
|