@seedcord/kit 0.0.1
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/LICENSE +190 -0
- package/README.md +13 -0
- package/dist/CustomId-5Zl_LdzZ.mjs +648 -0
- package/dist/CustomId-5Zl_LdzZ.mjs.map +1 -0
- package/dist/CustomId-BuIoGHXw.cjs +695 -0
- package/dist/CustomId-BuIoGHXw.cjs.map +1 -0
- package/dist/CustomId-CbTZuUup.d.mts +313 -0
- package/dist/index.cjs +84 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.mts +135 -0
- package/dist/index.mjs +77 -0
- package/dist/index.mjs.map +1 -0
- package/dist/internal.index.cjs +19 -0
- package/dist/internal.index.cjs.map +1 -0
- package/dist/internal.index.d.cts +1 -0
- package/dist/internal.index.d.mts +36 -0
- package/dist/internal.index.mjs +14 -0
- package/dist/internal.index.mjs.map +1 -0
- package/package.json +73 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
import { ActionRowBuilder, ButtonBuilder, ChannelSelectMenuBuilder, CheckboxBuilder, CheckboxGroupBuilder, CheckboxGroupOptionBuilder, ContainerBuilder, ContextMenuCommandBuilder, EmbedBuilder, FileBuilder, FileUploadBuilder, InteractionContextType, LabelBuilder, MediaGalleryBuilder, MentionableSelectMenuBuilder, ModalBuilder, RadioGroupBuilder, RadioGroupOptionBuilder, RoleSelectMenuBuilder, SectionBuilder, SeparatorBuilder, SlashCommandBuilder, SlashCommandSubcommandBuilder, SlashCommandSubcommandGroupBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, TextDisplayBuilder, TextInputBuilder, UserSelectMenuBuilder, resolveColor } from "discord.js";
|
|
2
|
+
import { SeedcordErrorCode } from "@seedcord/errors";
|
|
3
|
+
import { SeedcordError, SeedcordRangeError } from "@seedcord/errors/internal";
|
|
4
|
+
|
|
5
|
+
//#region src/botColorHolder.ts
|
|
6
|
+
const DEFAULT_COLOR = "Default";
|
|
7
|
+
let current = DEFAULT_COLOR;
|
|
8
|
+
/** @internal */
|
|
9
|
+
function setBotColor(color) {
|
|
10
|
+
current = color ?? DEFAULT_COLOR;
|
|
11
|
+
}
|
|
12
|
+
/** @internal */
|
|
13
|
+
function getBotColor() {
|
|
14
|
+
return current;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/components/builderTypes.ts
|
|
19
|
+
/**
|
|
20
|
+
* Available Discord.js builder classes for use with BuilderComponent for commands, embeds, modals, etc.
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
const BuilderTypes = {
|
|
25
|
+
command: SlashCommandBuilder,
|
|
26
|
+
context_menu: ContextMenuCommandBuilder,
|
|
27
|
+
subcommand: SlashCommandSubcommandBuilder,
|
|
28
|
+
group: SlashCommandSubcommandGroupBuilder,
|
|
29
|
+
embed: EmbedBuilder,
|
|
30
|
+
modal: ModalBuilder,
|
|
31
|
+
label: LabelBuilder,
|
|
32
|
+
text_input: TextInputBuilder,
|
|
33
|
+
file_upload: FileUploadBuilder,
|
|
34
|
+
checkbox: CheckboxBuilder,
|
|
35
|
+
checkbox_group: CheckboxGroupBuilder,
|
|
36
|
+
checkbox_group_option: CheckboxGroupOptionBuilder,
|
|
37
|
+
radio_group: RadioGroupBuilder,
|
|
38
|
+
radio_group_option: RadioGroupOptionBuilder,
|
|
39
|
+
button: ButtonBuilder,
|
|
40
|
+
menu_string: StringSelectMenuBuilder,
|
|
41
|
+
menu_option_string: StringSelectMenuOptionBuilder,
|
|
42
|
+
menu_user: UserSelectMenuBuilder,
|
|
43
|
+
menu_channel: ChannelSelectMenuBuilder,
|
|
44
|
+
menu_mentionable: MentionableSelectMenuBuilder,
|
|
45
|
+
menu_role: RoleSelectMenuBuilder,
|
|
46
|
+
container: ContainerBuilder,
|
|
47
|
+
text_display: TextDisplayBuilder,
|
|
48
|
+
file: FileBuilder,
|
|
49
|
+
media: MediaGalleryBuilder,
|
|
50
|
+
section: SectionBuilder,
|
|
51
|
+
separator: SeparatorBuilder
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Available Discord.js action row classes for use with RowComponent for Select Menus and Buttons
|
|
55
|
+
*
|
|
56
|
+
* @internal
|
|
57
|
+
*/
|
|
58
|
+
const RowTypes = {
|
|
59
|
+
button: ActionRowBuilder,
|
|
60
|
+
menu_string: ActionRowBuilder,
|
|
61
|
+
menu_user: ActionRowBuilder,
|
|
62
|
+
menu_channel: ActionRowBuilder,
|
|
63
|
+
menu_mentionable: ActionRowBuilder,
|
|
64
|
+
menu_role: ActionRowBuilder
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region src/components/Component.ts
|
|
69
|
+
/**
|
|
70
|
+
* Base class for Discord component wrappers.
|
|
71
|
+
*
|
|
72
|
+
* @typeParam TComponent - The Discord.js component type being wrapped
|
|
73
|
+
*
|
|
74
|
+
* @internal
|
|
75
|
+
*/
|
|
76
|
+
var BaseComponent = class {
|
|
77
|
+
_component;
|
|
78
|
+
constructor(ComponentClass) {
|
|
79
|
+
this._component = new ComponentClass();
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The wrapped builder, for calling Discord.js methods like setTitle() and setDescription() inside a subclass.
|
|
83
|
+
*
|
|
84
|
+
* @example this.instance.setTitle('My Modal')
|
|
85
|
+
*/
|
|
86
|
+
get instance() {
|
|
87
|
+
return this._component;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Base class for Discord.js builder components
|
|
92
|
+
*
|
|
93
|
+
* Wraps Discord.js builders (SlashCommandBuilder, EmbedBuilder, etc.) with
|
|
94
|
+
* Seedcord-specific defaults and helper methods.
|
|
95
|
+
*
|
|
96
|
+
* @typeParam BuilderKey - The type of Discord.js builder being wrapped
|
|
97
|
+
*/
|
|
98
|
+
var BuilderComponent = class extends BaseComponent {
|
|
99
|
+
type;
|
|
100
|
+
colorApplied = false;
|
|
101
|
+
constructor(type) {
|
|
102
|
+
const ComponentClass = BuilderTypes[type];
|
|
103
|
+
super(ComponentClass);
|
|
104
|
+
this.type = type;
|
|
105
|
+
if (this.instance instanceof SlashCommandBuilder || this.instance instanceof ContextMenuCommandBuilder) this.instance.setContexts(InteractionContextType.Guild);
|
|
106
|
+
}
|
|
107
|
+
get component() {
|
|
108
|
+
this.applyBotColor();
|
|
109
|
+
return this.instance;
|
|
110
|
+
}
|
|
111
|
+
applyBotColor() {
|
|
112
|
+
if (this.colorApplied) return;
|
|
113
|
+
this.colorApplied = true;
|
|
114
|
+
const color = getBotColor();
|
|
115
|
+
if (this.instance instanceof EmbedBuilder) {
|
|
116
|
+
if (this.instance.data.color === void 0) this.instance.setColor(color);
|
|
117
|
+
} else if (this.instance instanceof ContainerBuilder) {
|
|
118
|
+
const accent = this.instance.data.accent_color;
|
|
119
|
+
if (accent === null || accent === void 0) this.instance.setAccentColor(color === "Default" ? void 0 : resolveColor(color));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Base class for Discord action row components
|
|
125
|
+
*
|
|
126
|
+
* Wraps Discord.js action row builder with Seedcord-specific defaults and helper methods.
|
|
127
|
+
*
|
|
128
|
+
* @typeParam RowKey - The Discord.js action row type being wrapped
|
|
129
|
+
*/
|
|
130
|
+
var RowComponent = class extends BaseComponent {
|
|
131
|
+
type;
|
|
132
|
+
constructor(type) {
|
|
133
|
+
const ComponentClass = RowTypes[type];
|
|
134
|
+
super(ComponentClass);
|
|
135
|
+
this.type = type;
|
|
136
|
+
}
|
|
137
|
+
get component() {
|
|
138
|
+
return this.instance;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
//#endregion
|
|
143
|
+
//#region src/stops/Notice.ts
|
|
144
|
+
/**
|
|
145
|
+
* Base class for a user-facing refusal or a reported fault.
|
|
146
|
+
*
|
|
147
|
+
* Throw a `Notice` to stop a handler and reply to the user. The framework catches it at the controller
|
|
148
|
+
* boundary and renders {@link Notice.render}, which always decides what the user sees. With `report`
|
|
149
|
+
* false that render is all that happens. With `report` true the framework also logs the fault and
|
|
150
|
+
* publishes it to the `handledException` bus. A raw, non-Notice throw shows the generic message.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```ts
|
|
154
|
+
* import { Notice, BuilderComponent, type RenderContext, type ReplyResponse } from 'seedcord';
|
|
155
|
+
* import { TextDisplayBuilder } from 'discord.js';
|
|
156
|
+
*
|
|
157
|
+
* // reading `.component` applies the configured bot color to the container accent
|
|
158
|
+
* class TooPoorCard extends BuilderComponent<'container'> {
|
|
159
|
+
* constructor(balance: number) {
|
|
160
|
+
* super('container');
|
|
161
|
+
* this.instance.addTextDisplayComponents(
|
|
162
|
+
* new TextDisplayBuilder().setContent(`### Insufficient balance\nYou need more than ${balance} coins.`)
|
|
163
|
+
* );
|
|
164
|
+
* }
|
|
165
|
+
* }
|
|
166
|
+
*
|
|
167
|
+
* class TooPoor extends Notice {
|
|
168
|
+
* constructor(private readonly balance: number) {
|
|
169
|
+
* super(`balance ${balance} is below the cost`);
|
|
170
|
+
* }
|
|
171
|
+
*
|
|
172
|
+
* render(_ctx: RenderContext): ReplyResponse {
|
|
173
|
+
* return { components: [new TooPoorCard(this.balance).component] };
|
|
174
|
+
* }
|
|
175
|
+
* }
|
|
176
|
+
*
|
|
177
|
+
* // in a handler, throwing stops the handler and replies with render(ctx)
|
|
178
|
+
* if (wallet.balance < cost) throw new TooPoor(wallet.balance);
|
|
179
|
+
* ```
|
|
180
|
+
*/
|
|
181
|
+
var Notice = class extends Error {
|
|
182
|
+
/**
|
|
183
|
+
* Whether this denial is a reported fault. True also logs it and publishes it to the `handledException`
|
|
184
|
+
* bus. The user always sees {@link Notice.render} either way.
|
|
185
|
+
*
|
|
186
|
+
* @defaultValue `false`
|
|
187
|
+
*/
|
|
188
|
+
report = false;
|
|
189
|
+
/**
|
|
190
|
+
* Whether the reply is ephemeral, so only the invoking user sees it. Set it false for a refusal the
|
|
191
|
+
* whole channel should see.
|
|
192
|
+
*
|
|
193
|
+
* @defaultValue `true`
|
|
194
|
+
*/
|
|
195
|
+
ephemeral = true;
|
|
196
|
+
/**
|
|
197
|
+
* A short one-line reason. When every arm of an `or` gate refuses and each refusal sets this, `or`
|
|
198
|
+
* lists them instead of showing a neutral message.
|
|
199
|
+
*/
|
|
200
|
+
summary;
|
|
201
|
+
constructor(message, options) {
|
|
202
|
+
super(message, options);
|
|
203
|
+
this.name = new.target.name;
|
|
204
|
+
Error.captureStackTrace(this, this.constructor);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region src/stops/NoticeCard.ts
|
|
210
|
+
/**
|
|
211
|
+
* Built fresh inside a {@link Notice}'s `render` to back its ComponentsV2 reply. The title renders as
|
|
212
|
+
* an h3 line with the description on the next line.
|
|
213
|
+
*/
|
|
214
|
+
var NoticeCard = class extends BuilderComponent {
|
|
215
|
+
constructor(description, title = "Cannot Proceed") {
|
|
216
|
+
super("container");
|
|
217
|
+
this.instance.addTextDisplayComponents(new TextDisplayBuilder().setContent(`### ${title}\n${description}`));
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
//#endregion
|
|
222
|
+
//#region src/customId/Errors.ts
|
|
223
|
+
/**
|
|
224
|
+
* Thrown when a customId was minted by an older version of its shape.
|
|
225
|
+
*
|
|
226
|
+
* This is normal after the shape changes. The reply tells the user to run the command again.
|
|
227
|
+
*/
|
|
228
|
+
var StaleCustomId = class extends Notice {
|
|
229
|
+
constructor(prefix) {
|
|
230
|
+
super(`Stale customId for "${prefix}".`);
|
|
231
|
+
}
|
|
232
|
+
render() {
|
|
233
|
+
return { components: [new NoticeCard("This button or menu is from an older version. Please run the command again.", "Outdated").component] };
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
/**
|
|
237
|
+
* Thrown when a customId wire is corrupt or tampered with and cannot be trusted.
|
|
238
|
+
*
|
|
239
|
+
* This should not happen in normal use, so it reports.
|
|
240
|
+
*/
|
|
241
|
+
var InvalidCustomId = class extends Notice {
|
|
242
|
+
constructor(detail) {
|
|
243
|
+
super(`Invalid customId. ${detail}`);
|
|
244
|
+
this.report = true;
|
|
245
|
+
}
|
|
246
|
+
render() {
|
|
247
|
+
return { components: [new NoticeCard("Something went wrong. Please try again.").component] };
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
//#endregion
|
|
252
|
+
//#region src/customId/codec.ts
|
|
253
|
+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
254
|
+
const BASE = 64n;
|
|
255
|
+
const CHAR_TO_VALUE = new Map([...ALPHABET].map((char, index) => [char, index]));
|
|
256
|
+
const DELIMITER = "";
|
|
257
|
+
const ESCAPE = "\x1B";
|
|
258
|
+
/** @internal */
|
|
259
|
+
const HASH_LENGTH = 3;
|
|
260
|
+
const SAFE_MAX = BigInt(Number.MAX_SAFE_INTEGER);
|
|
261
|
+
const SAFE_MIN = BigInt(Number.MIN_SAFE_INTEGER);
|
|
262
|
+
function bigintToBase64(value) {
|
|
263
|
+
if (value === 0n) return ALPHABET.charAt(0);
|
|
264
|
+
let text = "";
|
|
265
|
+
for (let remaining = value; remaining > 0n; remaining /= BASE) text = ALPHABET.charAt(Number(remaining % BASE)) + text;
|
|
266
|
+
return text;
|
|
267
|
+
}
|
|
268
|
+
function base64ToBigint(text) {
|
|
269
|
+
let value = 0n;
|
|
270
|
+
for (const char of text) {
|
|
271
|
+
const digit = CHAR_TO_VALUE.get(char);
|
|
272
|
+
if (digit === void 0) throw new InvalidCustomId(`bad character ${JSON.stringify(char)}`);
|
|
273
|
+
value = value * BASE + BigInt(digit);
|
|
274
|
+
}
|
|
275
|
+
return value;
|
|
276
|
+
}
|
|
277
|
+
function zigzagEncode(value) {
|
|
278
|
+
const big = BigInt(value);
|
|
279
|
+
return big >= 0n ? big << 1n : (-big << 1n) - 1n;
|
|
280
|
+
}
|
|
281
|
+
function zigzagDecode(encoded) {
|
|
282
|
+
return (encoded & 1n) === 1n ? -(encoded + 1n >> 1n) : encoded >> 1n;
|
|
283
|
+
}
|
|
284
|
+
function escapeToken(text) {
|
|
285
|
+
return text.replace(/[\x1b\x1f]/g, (char) => ESCAPE + char);
|
|
286
|
+
}
|
|
287
|
+
function unescapeToken(text) {
|
|
288
|
+
let out = "";
|
|
289
|
+
for (let i = 0; i < text.length; i++) {
|
|
290
|
+
if (text.charAt(i) !== ESCAPE) {
|
|
291
|
+
out += text.charAt(i);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const next = text.charAt(i + 1);
|
|
295
|
+
if (next === "") throw new InvalidCustomId("dangling escape at end of token");
|
|
296
|
+
out += next;
|
|
297
|
+
i++;
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
function splitTokens(body) {
|
|
302
|
+
const pieces = [];
|
|
303
|
+
let current = "";
|
|
304
|
+
for (let i = 0; i < body.length; i++) {
|
|
305
|
+
const char = body.charAt(i);
|
|
306
|
+
if (char === ESCAPE) {
|
|
307
|
+
current += char + body.charAt(i + 1);
|
|
308
|
+
i++;
|
|
309
|
+
} else if (char === DELIMITER) {
|
|
310
|
+
pieces.push(current);
|
|
311
|
+
current = "";
|
|
312
|
+
} else current += char;
|
|
313
|
+
}
|
|
314
|
+
pieces.push(current);
|
|
315
|
+
return pieces;
|
|
316
|
+
}
|
|
317
|
+
function isBounded(field) {
|
|
318
|
+
if (field.kind === "int") return field.min !== void 0 && field.max !== void 0;
|
|
319
|
+
return field.kind === "snowflake" || field.kind === "uuid" || field.kind === "bool" || field.kind === "oneOf";
|
|
320
|
+
}
|
|
321
|
+
function radixOf(field) {
|
|
322
|
+
switch (field.kind) {
|
|
323
|
+
case "snowflake": return 1n << 64n;
|
|
324
|
+
case "uuid": return 1n << 128n;
|
|
325
|
+
case "bool": return 2n;
|
|
326
|
+
case "oneOf":
|
|
327
|
+
if (!field.choices?.length) throw new InvalidCustomId("oneOf field has no choices");
|
|
328
|
+
return BigInt(field.choices.length);
|
|
329
|
+
case "int":
|
|
330
|
+
if (field.min === void 0 || field.max === void 0) throw new InvalidCustomId("bounded int field is missing a bound");
|
|
331
|
+
return BigInt(field.max) - BigInt(field.min) + 1n;
|
|
332
|
+
default: throw new InvalidCustomId(`field kind ${field.kind} has no radix`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
function boundedToBigint(field, name, value) {
|
|
336
|
+
const slot = boundedSlot(field, name, value);
|
|
337
|
+
if (slot < 0n || slot >= radixOf(field)) outOfRange(name, value);
|
|
338
|
+
return slot;
|
|
339
|
+
}
|
|
340
|
+
function boundedSlot(field, name, value) {
|
|
341
|
+
switch (field.kind) {
|
|
342
|
+
case "snowflake":
|
|
343
|
+
if (typeof value !== "string" || !/^\d+$/.test(value)) return outOfRange(name, value);
|
|
344
|
+
return BigInt(value);
|
|
345
|
+
case "uuid": {
|
|
346
|
+
if (typeof value !== "string") return outOfRange(name, value);
|
|
347
|
+
const hex = value.replace(/-/g, "");
|
|
348
|
+
if (!/^[0-9a-fA-F]{32}$/.test(hex)) return outOfRange(name, value);
|
|
349
|
+
return BigInt(`0x${hex}`);
|
|
350
|
+
}
|
|
351
|
+
case "bool": return value ? 1n : 0n;
|
|
352
|
+
case "oneOf": {
|
|
353
|
+
const index = (field.choices ?? []).indexOf(value);
|
|
354
|
+
return index < 0 ? outOfRange(name, value) : BigInt(index);
|
|
355
|
+
}
|
|
356
|
+
case "int":
|
|
357
|
+
if (!Number.isInteger(value)) return outOfRange(name, value);
|
|
358
|
+
return BigInt(value - (field.min ?? 0));
|
|
359
|
+
default: return outOfRange(name, value);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function outOfRange(name, value) {
|
|
363
|
+
throw new SeedcordRangeError(SeedcordErrorCode.CustomIdValueOutOfRange, [name, String(value)]);
|
|
364
|
+
}
|
|
365
|
+
function bigintToBoundedValue(field, slot) {
|
|
366
|
+
switch (field.kind) {
|
|
367
|
+
case "snowflake": return slot.toString();
|
|
368
|
+
case "uuid": return bigintToUuid(slot);
|
|
369
|
+
case "bool": return slot === 1n;
|
|
370
|
+
case "oneOf": return (field.choices ?? [])[Number(slot)];
|
|
371
|
+
case "int": return Number(slot) + (field.min ?? 0);
|
|
372
|
+
default: throw new InvalidCustomId(`field kind ${field.kind} is not bounded`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function bigintToUuid(value) {
|
|
376
|
+
const hex = value.toString(16).padStart(32, "0");
|
|
377
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
378
|
+
}
|
|
379
|
+
function encodeUnboundedToken(field, name, value) {
|
|
380
|
+
if (field.kind === "int") {
|
|
381
|
+
if (!Number.isSafeInteger(value)) outOfRange(name, value);
|
|
382
|
+
return bigintToBase64(zigzagEncode(value));
|
|
383
|
+
}
|
|
384
|
+
return escapeToken(value);
|
|
385
|
+
}
|
|
386
|
+
function decodeUnboundedToken(field, piece) {
|
|
387
|
+
if (field.kind !== "int") return unescapeToken(piece);
|
|
388
|
+
if (piece === "") throw new InvalidCustomId("empty integer token");
|
|
389
|
+
const decoded = zigzagDecode(base64ToBigint(piece));
|
|
390
|
+
if (decoded > SAFE_MAX || decoded < SAFE_MIN) throw new InvalidCustomId("integer out of safe range");
|
|
391
|
+
return Number(decoded);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* A short fingerprint of the shape. Change the shape and the hash changes, so an old customId no
|
|
395
|
+
* longer matches the current routeKey and decode catches it as stale.
|
|
396
|
+
*
|
|
397
|
+
* @internal
|
|
398
|
+
*/
|
|
399
|
+
function computeLayoutHash(shape) {
|
|
400
|
+
const signature = JSON.stringify(Object.entries(shape).map(([name, field]) => [
|
|
401
|
+
name,
|
|
402
|
+
field.kind,
|
|
403
|
+
isBounded(field),
|
|
404
|
+
field.kind === "oneOf" ? field.choices ?? [] : null,
|
|
405
|
+
field.kind === "int" ? [field.min ?? null, field.max ?? null] : null
|
|
406
|
+
]));
|
|
407
|
+
const modulus = BASE ** BigInt(3);
|
|
408
|
+
let hash = 0n;
|
|
409
|
+
for (const char of signature) hash = (hash * 131n + BigInt(char.charCodeAt(0))) % modulus;
|
|
410
|
+
let text = "";
|
|
411
|
+
for (let i = 0; i < 3; i++) {
|
|
412
|
+
text = ALPHABET.charAt(Number(hash % BASE)) + text;
|
|
413
|
+
hash /= BASE;
|
|
414
|
+
}
|
|
415
|
+
return text;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Pack values into a body. Bounded fields fold into one integer, unbounded fields trail after it.
|
|
419
|
+
*
|
|
420
|
+
* @internal
|
|
421
|
+
*/
|
|
422
|
+
function encodeBody(shape, values) {
|
|
423
|
+
const fields = Object.entries(shape);
|
|
424
|
+
const pieces = [];
|
|
425
|
+
const bounded = fields.filter(([, field]) => isBounded(field));
|
|
426
|
+
if (bounded.length > 0) {
|
|
427
|
+
let packed = 0n;
|
|
428
|
+
for (const [name, field] of bounded) packed = packed * radixOf(field) + boundedToBigint(field, name, values[name]);
|
|
429
|
+
pieces.push(bigintToBase64(packed));
|
|
430
|
+
}
|
|
431
|
+
for (const [name, field] of fields) if (!isBounded(field)) pieces.push(encodeUnboundedToken(field, name, values[name]));
|
|
432
|
+
return pieces.join(DELIMITER);
|
|
433
|
+
}
|
|
434
|
+
function unpackBounded(bounded, blob, result) {
|
|
435
|
+
if (blob === void 0 || blob === "") throw new InvalidCustomId("empty packed block");
|
|
436
|
+
let packed = base64ToBigint(blob);
|
|
437
|
+
for (const [name, field] of [...bounded].reverse()) {
|
|
438
|
+
const radix = radixOf(field);
|
|
439
|
+
result[name] = bigintToBoundedValue(field, packed % radix);
|
|
440
|
+
packed /= radix;
|
|
441
|
+
}
|
|
442
|
+
if (packed !== 0n) throw new InvalidCustomId("leftover bits after unpacking");
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Reverse of encodeBody. Rejects any malformed or truncated body.
|
|
446
|
+
*
|
|
447
|
+
* @internal
|
|
448
|
+
*/
|
|
449
|
+
function decodeBody(shape, body) {
|
|
450
|
+
const fields = Object.entries(shape);
|
|
451
|
+
const bounded = fields.filter(([, field]) => isBounded(field));
|
|
452
|
+
const unbounded = fields.filter(([, field]) => !isBounded(field));
|
|
453
|
+
const expected = (bounded.length > 0 ? 1 : 0) + unbounded.length;
|
|
454
|
+
if (expected === 0) {
|
|
455
|
+
if (body !== "") throw new InvalidCustomId(`expected an empty body, got ${JSON.stringify(body)}`);
|
|
456
|
+
return {};
|
|
457
|
+
}
|
|
458
|
+
const pieces = splitTokens(body);
|
|
459
|
+
if (pieces.length !== expected) throw new InvalidCustomId(`expected ${expected} piece(s), got ${pieces.length}`);
|
|
460
|
+
const result = {};
|
|
461
|
+
let cursor = 0;
|
|
462
|
+
if (bounded.length > 0) {
|
|
463
|
+
unpackBounded(bounded, pieces[cursor], result);
|
|
464
|
+
cursor++;
|
|
465
|
+
}
|
|
466
|
+
for (const [name, field] of unbounded) {
|
|
467
|
+
const piece = pieces[cursor];
|
|
468
|
+
cursor++;
|
|
469
|
+
if (piece === void 0) throw new InvalidCustomId("missing trailing piece");
|
|
470
|
+
result[name] = decodeUnboundedToken(field, piece);
|
|
471
|
+
}
|
|
472
|
+
return result;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
//#endregion
|
|
476
|
+
//#region src/customId/CustomId.ts
|
|
477
|
+
const MAX_WIRE_LENGTH = 100;
|
|
478
|
+
function routeKeyOf(wire) {
|
|
479
|
+
const colon = wire.indexOf(":");
|
|
480
|
+
return colon < 0 ? "" : wire.slice(0, colon);
|
|
481
|
+
}
|
|
482
|
+
/** Strip the layout hash off the routeKey to recover the stable prefix the controller routes by. @internal */
|
|
483
|
+
function prefixOf(wire) {
|
|
484
|
+
const key = routeKeyOf(wire);
|
|
485
|
+
return key.length <= 3 ? "" : key.slice(0, key.length - 3);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* A typed customId. The single source of truth shared by the component that mints it and the handler
|
|
489
|
+
* that reads it. This gives you typed reads on the `.customId` field in components. Values are packed into a compact wire string rather than plain stringified tokens, so the 100-char Discord limit goes further. More string per string, basically.
|
|
490
|
+
*
|
|
491
|
+
* @typeParam Prefix - The stable route prefix, e.g. 'approve'.
|
|
492
|
+
* @typeParam Shape - The accumulated fields, filled in by the chain.
|
|
493
|
+
*
|
|
494
|
+
* @example
|
|
495
|
+
* ```ts
|
|
496
|
+
* const ApproveId = new CustomId('approve')
|
|
497
|
+
* .snowflake('userId')
|
|
498
|
+
* .oneOf('action', ['approve', 'deny']);
|
|
499
|
+
*
|
|
500
|
+
* // Set the custom id on a button when creating it.
|
|
501
|
+
* new ButtonBuilder().setCustomId(ApproveId.encode({ userId: '123', action: 'approve' }));
|
|
502
|
+
*
|
|
503
|
+
* // reading in the handler: userId comes back a string
|
|
504
|
+
* const { userId, action } = this.params; // userId: string, action: 'approve' | 'deny'
|
|
505
|
+
* await this.event.guild?.members.fetch(userId);
|
|
506
|
+
* ```
|
|
507
|
+
*/
|
|
508
|
+
var CustomId = class CustomId {
|
|
509
|
+
prefix;
|
|
510
|
+
shape;
|
|
511
|
+
/** The prefix plus a short hash of the shape, the part of the wire before the colon. */
|
|
512
|
+
routeKey;
|
|
513
|
+
constructor(prefix, shape = {}) {
|
|
514
|
+
if (!prefix || /[:\x1b\x1f]/.test(prefix)) throw new SeedcordError(SeedcordErrorCode.CustomIdInvalidPrefix, [prefix]);
|
|
515
|
+
this.prefix = prefix;
|
|
516
|
+
this.shape = shape;
|
|
517
|
+
this.routeKey = prefix + computeLayoutHash(shape);
|
|
518
|
+
}
|
|
519
|
+
add(name, field) {
|
|
520
|
+
if (/^(?:0|[1-9]\d*)$/.test(name)) throw new SeedcordError(SeedcordErrorCode.CustomIdReservedFieldName, [name]);
|
|
521
|
+
if (name in this.shape) throw new SeedcordError(SeedcordErrorCode.CustomIdDuplicateFieldName, [name]);
|
|
522
|
+
const shape = {
|
|
523
|
+
...this.shape,
|
|
524
|
+
[name]: field
|
|
525
|
+
};
|
|
526
|
+
return new CustomId(this.prefix, shape);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Add a Discord ID field, decoded as a string (the discord.js `Snowflake` type).
|
|
530
|
+
*
|
|
531
|
+
* @example
|
|
532
|
+
* ```ts
|
|
533
|
+
* new CustomId('ban').snowflake('userId');
|
|
534
|
+
* ```
|
|
535
|
+
*/
|
|
536
|
+
snowflake(name) {
|
|
537
|
+
return this.add(name, { kind: "snowflake" });
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Add a UUID field, decoded as a lowercase uuid string.
|
|
541
|
+
*
|
|
542
|
+
* @example
|
|
543
|
+
* ```ts
|
|
544
|
+
* new CustomId('ticket').uuid('ticketId');
|
|
545
|
+
* ```
|
|
546
|
+
*/
|
|
547
|
+
uuid(name) {
|
|
548
|
+
return this.add(name, { kind: "uuid" });
|
|
549
|
+
}
|
|
550
|
+
int(name, min, max) {
|
|
551
|
+
if (min !== void 0 && max !== void 0 && min > max) throw new SeedcordError(SeedcordErrorCode.CustomIdInvalidBounds, [
|
|
552
|
+
name,
|
|
553
|
+
min,
|
|
554
|
+
max
|
|
555
|
+
]);
|
|
556
|
+
const field = min === void 0 || max === void 0 ? { kind: "int" } : {
|
|
557
|
+
kind: "int",
|
|
558
|
+
min,
|
|
559
|
+
max
|
|
560
|
+
};
|
|
561
|
+
return this.add(name, field);
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Add a boolean flag.
|
|
565
|
+
*
|
|
566
|
+
* @example
|
|
567
|
+
* ```ts
|
|
568
|
+
* new CustomId('settings').bool('silent');
|
|
569
|
+
* ```
|
|
570
|
+
*/
|
|
571
|
+
bool(name) {
|
|
572
|
+
return this.add(name, { kind: "bool" });
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Add a field that is one value from a fixed list, decoded as the literal union. No `as const` needed.
|
|
576
|
+
*
|
|
577
|
+
* @example
|
|
578
|
+
* ```ts
|
|
579
|
+
* new CustomId('poll').oneOf('choice', ['yes', 'no', 'abstain']);
|
|
580
|
+
* ```
|
|
581
|
+
*/
|
|
582
|
+
oneOf(name, choices) {
|
|
583
|
+
if (choices.length === 0) throw new SeedcordError(SeedcordErrorCode.CustomIdEmptyChoices, [name]);
|
|
584
|
+
return this.add(name, {
|
|
585
|
+
kind: "oneOf",
|
|
586
|
+
choices
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Add a free short text field. Avoid it where possible, it cannot be packed so it costs the most wire space.
|
|
591
|
+
*
|
|
592
|
+
* @example
|
|
593
|
+
* ```ts
|
|
594
|
+
* new CustomId('note').str('message');
|
|
595
|
+
* ```
|
|
596
|
+
*/
|
|
597
|
+
str(name) {
|
|
598
|
+
return this.add(name, { kind: "string" });
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Mint a wire string from values. Throws if a value is out of its field's range or the wire is over 100 chars.
|
|
602
|
+
*
|
|
603
|
+
* @param values - One value per field, typed by the chain.
|
|
604
|
+
* @returns The wire string to put on the component's customId.
|
|
605
|
+
*/
|
|
606
|
+
encode(values) {
|
|
607
|
+
const wire = `${this.routeKey}:${encodeBody(this.shape, values)}`;
|
|
608
|
+
if (wire.length > MAX_WIRE_LENGTH) throw new SeedcordRangeError(SeedcordErrorCode.CustomIdWireTooLong, [wire.length]);
|
|
609
|
+
return wire;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Read a wire string back into values.
|
|
613
|
+
*
|
|
614
|
+
* @param wire - The customId string from the interaction.
|
|
615
|
+
* @returns The decoded values, typed by the chain.
|
|
616
|
+
* @throws A {@link StaleCustomId} when the shape changed since the wire was minted.
|
|
617
|
+
* @throws An {@link InvalidCustomId} on a corrupt or foreign wire.
|
|
618
|
+
*/
|
|
619
|
+
decode(wire) {
|
|
620
|
+
const key = routeKeyOf(wire);
|
|
621
|
+
if (key !== this.routeKey) {
|
|
622
|
+
if (prefixOf(wire) === this.prefix) throw new StaleCustomId(this.prefix);
|
|
623
|
+
throw new InvalidCustomId(`routeKey ${JSON.stringify(key)} is not ${JSON.stringify(this.routeKey)}`);
|
|
624
|
+
}
|
|
625
|
+
return decodeBody(this.shape, wire.slice(key.length + 1));
|
|
626
|
+
}
|
|
627
|
+
/** True if this wire was minted from this customId's prefix, ignoring the shape hash. */
|
|
628
|
+
owns(wire) {
|
|
629
|
+
return prefixOf(wire) === this.prefix;
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
/**
|
|
633
|
+
* Find the customId whose prefix owns this wire, decode against it, and report which one matched.
|
|
634
|
+
*
|
|
635
|
+
* @internal
|
|
636
|
+
*/
|
|
637
|
+
function decodeFor(defs, wire) {
|
|
638
|
+
const match = defs.find((def) => def.owns(wire));
|
|
639
|
+
if (!match) throw new InvalidCustomId(`no customId owns ${JSON.stringify(routeKeyOf(wire))}`);
|
|
640
|
+
return {
|
|
641
|
+
prefix: match.prefix,
|
|
642
|
+
params: match.decode(wire)
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
//#endregion
|
|
647
|
+
export { Notice as a, setBotColor as c, NoticeCard as i, decodeFor as n, BuilderComponent as o, prefixOf as r, RowComponent as s, CustomId as t };
|
|
648
|
+
//# sourceMappingURL=CustomId-5Zl_LdzZ.mjs.map
|