@seedcord/core 0.1.0-next.0 → 0.1.0-next.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,820 @@
1
+ import { SeedcordErrorCode } from "@seedcord/errors";
2
+ import { SeedcordError, SeedcordRangeError, SeedcordTypeError } from "@seedcord/errors/internal";
3
+ import { toEpochSeconds } from "@seedcord/utils";
4
+ import { ActionRowBuilder, ButtonBuilder, ChannelSelectMenuBuilder, CheckboxBuilder, CheckboxGroupBuilder, CheckboxGroupOptionBuilder, ContainerBuilder, ContextMenuCommandBuilder, EmbedBuilder, FileBuilder, FileUploadBuilder, LabelBuilder, MediaGalleryBuilder, MentionableSelectMenuBuilder, ModalBuilder, RadioGroupBuilder, RadioGroupOptionBuilder, RoleSelectMenuBuilder, SectionBuilder, SeparatorBuilder, SlashCommandBuilder, SlashCommandSubcommandBuilder, SlashCommandSubcommandGroupBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, TextDisplayBuilder, TextInputBuilder, UserSelectMenuBuilder } from "@discordjs/builders";
5
+ import { InteractionContextType } from "discord-api-types/v10";
6
+
7
+ //#region src/metadataKeys.ts
8
+ let InteractionRoutes = /* @__PURE__ */ function(InteractionRoutes) {
9
+ InteractionRoutes["Slash"] = "slash";
10
+ InteractionRoutes["Button"] = "button";
11
+ InteractionRoutes["Modal"] = "modal";
12
+ InteractionRoutes["StringMenu"] = "stringMenu";
13
+ InteractionRoutes["UserMenu"] = "userMenu";
14
+ InteractionRoutes["RoleMenu"] = "roleMenu";
15
+ InteractionRoutes["ChannelMenu"] = "channelMenu";
16
+ InteractionRoutes["MentionableMenu"] = "mentionableMenu";
17
+ InteractionRoutes["MessageContextMenu"] = "messageContextMenu";
18
+ InteractionRoutes["UserContextMenu"] = "userContextMenu";
19
+ InteractionRoutes["Autocomplete"] = "autocomplete";
20
+ return InteractionRoutes;
21
+ }({});
22
+ const InteractionMetadataKey = Symbol("seedcord:interaction:metadata");
23
+ const InteractionRouteKeys = {
24
+ ["slash"]: Symbol("seedcord:interaction:slash"),
25
+ ["button"]: Symbol("seedcord:interaction:button"),
26
+ ["modal"]: Symbol("seedcord:interaction:modal"),
27
+ ["stringMenu"]: Symbol("seedcord:interaction:stringMenu"),
28
+ ["userMenu"]: Symbol("seedcord:interaction:userMenu"),
29
+ ["roleMenu"]: Symbol("seedcord:interaction:roleMenu"),
30
+ ["channelMenu"]: Symbol("seedcord:interaction:channelMenu"),
31
+ ["mentionableMenu"]: Symbol("seedcord:interaction:mentionableMenu"),
32
+ ["messageContextMenu"]: Symbol("seedcord:interaction:messageContextMenu"),
33
+ ["userContextMenu"]: Symbol("seedcord:interaction:userContextMenu"),
34
+ ["autocomplete"]: Symbol("seedcord:interaction:autocomplete")
35
+ };
36
+ const MiddlewareMetadataKey = Symbol("seedcord:middleware:metadata");
37
+ const EventMetadataKey = Symbol("seedcord:event:metadata");
38
+ const CommandMetadataKey = Symbol("seedcord:command:metadata");
39
+ const GatedMetadataKey = Symbol("seedcord:gated:metadata");
40
+ const SubscribeMetadataKey = Symbol("seedcord:subscribe:metadata");
41
+
42
+ //#endregion
43
+ //#region src/stops/Notice.ts
44
+ /**
45
+ * Base class for a user-facing refusal or a reported fault.
46
+ *
47
+ * Throw a `Notice` to stop a handler and reply to the user. The framework catches it at the controller
48
+ * boundary and renders {@link Notice.render}, which always decides what the user sees. With `report`
49
+ * false that render is all that happens. With `report` true the framework also logs the fault and
50
+ * publishes it to the `handledException` bus. A raw, non-Notice throw shows the generic message.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * import { Notice, BuilderComponent, type RenderContext, type ReplyResponse } from 'seedcord';
55
+ * import { TextDisplayBuilder } from '@discordjs/builders';
56
+ *
57
+ * // reading `.component` applies the configured bot color to the container accent
58
+ * class TooPoorCard extends BuilderComponent<'container'> {
59
+ * constructor(balance: number) {
60
+ * super('container');
61
+ * this.instance.addTextDisplayComponents(
62
+ * new TextDisplayBuilder().setContent(`### Insufficient balance\nYou need more than ${balance} coins.`)
63
+ * );
64
+ * }
65
+ * }
66
+ *
67
+ * class TooPoor extends Notice {
68
+ * constructor(private readonly balance: number) {
69
+ * super(`balance ${balance} is below the cost`);
70
+ * }
71
+ *
72
+ * render(_ctx: RenderContext): ReplyResponse {
73
+ * return { components: [new TooPoorCard(this.balance).component] };
74
+ * }
75
+ * }
76
+ *
77
+ * // in a handler, throwing stops the handler and replies with render(ctx)
78
+ * if (wallet.balance < cost) throw new TooPoor(wallet.balance);
79
+ * ```
80
+ */
81
+ var Notice = class extends Error {
82
+ /**
83
+ * Whether this denial is a reported fault. True also logs it and publishes it to the `handledException`
84
+ * bus. The user always sees {@link Notice.render} either way.
85
+ *
86
+ * @defaultValue `false`
87
+ */
88
+ report = false;
89
+ /**
90
+ * Whether the reply is ephemeral, so only the invoking user sees it. Set it false for a refusal the
91
+ * whole channel should see.
92
+ *
93
+ * @defaultValue `true`
94
+ */
95
+ ephemeral = true;
96
+ /**
97
+ * A short one-line reason. When every arm of an `or` gate refuses and each refusal sets this, `or`
98
+ * lists them instead of showing a neutral message.
99
+ */
100
+ summary;
101
+ constructor(message, options) {
102
+ super(message, options);
103
+ this.name = new.target.name;
104
+ Error.captureStackTrace(this, this.constructor);
105
+ }
106
+ };
107
+
108
+ //#endregion
109
+ //#region src/components/botColorHolder.ts
110
+ const DEFAULT_COLOR = "Default";
111
+ let current = DEFAULT_COLOR;
112
+ function setBotColor(color) {
113
+ current = color ?? DEFAULT_COLOR;
114
+ }
115
+ function getBotColor() {
116
+ return current;
117
+ }
118
+
119
+ //#endregion
120
+ //#region src/components/builderTypes.ts
121
+ /**
122
+ * Available Discord.js builder classes for use with BuilderComponent for commands, embeds, modals, etc.
123
+ *
124
+ * @internal
125
+ */
126
+ const BuilderTypes = {
127
+ command: SlashCommandBuilder,
128
+ context_menu: ContextMenuCommandBuilder,
129
+ subcommand: SlashCommandSubcommandBuilder,
130
+ group: SlashCommandSubcommandGroupBuilder,
131
+ embed: EmbedBuilder,
132
+ modal: ModalBuilder,
133
+ label: LabelBuilder,
134
+ text_input: TextInputBuilder,
135
+ file_upload: FileUploadBuilder,
136
+ checkbox: CheckboxBuilder,
137
+ checkbox_group: CheckboxGroupBuilder,
138
+ checkbox_group_option: CheckboxGroupOptionBuilder,
139
+ radio_group: RadioGroupBuilder,
140
+ radio_group_option: RadioGroupOptionBuilder,
141
+ button: ButtonBuilder,
142
+ menu_string: StringSelectMenuBuilder,
143
+ menu_option_string: StringSelectMenuOptionBuilder,
144
+ menu_user: UserSelectMenuBuilder,
145
+ menu_channel: ChannelSelectMenuBuilder,
146
+ menu_mentionable: MentionableSelectMenuBuilder,
147
+ menu_role: RoleSelectMenuBuilder,
148
+ container: ContainerBuilder,
149
+ text_display: TextDisplayBuilder,
150
+ file: FileBuilder,
151
+ media: MediaGalleryBuilder,
152
+ section: SectionBuilder,
153
+ separator: SeparatorBuilder
154
+ };
155
+ /**
156
+ * Available Discord.js action row classes for use with RowComponent for Select Menus and Buttons
157
+ *
158
+ * @internal
159
+ */
160
+ const RowTypes = {
161
+ button: ActionRowBuilder,
162
+ menu_string: ActionRowBuilder,
163
+ menu_user: ActionRowBuilder,
164
+ menu_channel: ActionRowBuilder,
165
+ menu_mentionable: ActionRowBuilder,
166
+ menu_role: ActionRowBuilder
167
+ };
168
+
169
+ //#endregion
170
+ //#region src/components/colors.ts
171
+ const Colors = {
172
+ Aqua: 1752220,
173
+ Blue: 3447003,
174
+ Blurple: 5793266,
175
+ DarkAqua: 1146986,
176
+ DarkBlue: 2123412,
177
+ DarkButNotBlack: 2895667,
178
+ DarkerGrey: 8359053,
179
+ DarkGold: 12745742,
180
+ DarkGreen: 2067276,
181
+ DarkGrey: 9936031,
182
+ DarkNavy: 2899536,
183
+ DarkOrange: 11027200,
184
+ DarkPurple: 7419530,
185
+ DarkRed: 10038562,
186
+ DarkVividPink: 11342935,
187
+ Default: 0,
188
+ Fuchsia: 15418782,
189
+ Gold: 15844367,
190
+ Green: 5763719,
191
+ Grey: 9807270,
192
+ Greyple: 10070709,
193
+ LightGrey: 12370112,
194
+ LuminousVividPink: 15277667,
195
+ Navy: 3426654,
196
+ NotQuiteBlack: 2303786,
197
+ Orange: 15105570,
198
+ Purple: 10181046,
199
+ Red: 15548997,
200
+ White: 16777215,
201
+ Yellow: 16705372
202
+ };
203
+
204
+ //#endregion
205
+ //#region src/components/resolveColor.ts
206
+ const MAX_COLOR = 16777215;
207
+ const RED_SHIFT = 16;
208
+ const GREEN_SHIFT = 8;
209
+ const palette = Colors;
210
+ /**
211
+ * Resolves a {@link BotColor} to the integer Discord expects.
212
+ *
213
+ * Ported from discord.js (Apache-2.0), `resolveColor` in src/util/Util.js.
214
+ */
215
+ function resolveColor(color) {
216
+ let resolved;
217
+ if (typeof color === "string") {
218
+ if (color === "Random") return Math.floor(Math.random() * 16777216);
219
+ if (color === "Default") return 0;
220
+ if (/^#?[\da-f]{6}$/i.test(color)) return Number.parseInt(color.replace("#", ""), 16);
221
+ resolved = palette[color];
222
+ } else if (typeof color === "number") resolved = color;
223
+ else resolved = (color[0] << RED_SHIFT) + (color[1] << GREEN_SHIFT) + color[2];
224
+ if (resolved === void 0 || !Number.isSafeInteger(resolved)) throw new SeedcordTypeError(SeedcordErrorCode.ColorUnresolvable, [JSON.stringify(color)]);
225
+ if (resolved < 0 || resolved > MAX_COLOR) throw new SeedcordRangeError(SeedcordErrorCode.ColorOutOfRange);
226
+ return resolved;
227
+ }
228
+
229
+ //#endregion
230
+ //#region src/components/Component.ts
231
+ /**
232
+ * Base class for Discord component wrappers.
233
+ *
234
+ * @typeParam TComponent - The Discord.js component type being wrapped
235
+ *
236
+ * @internal
237
+ */
238
+ var BaseComponent = class {
239
+ _component;
240
+ constructor(ComponentClass) {
241
+ this._component = new ComponentClass();
242
+ }
243
+ /**
244
+ * The wrapped builder, for calling Discord.js methods like setTitle() and setDescription() inside a subclass.
245
+ *
246
+ * @example this.instance.setTitle('My Modal')
247
+ */
248
+ get instance() {
249
+ return this._component;
250
+ }
251
+ };
252
+ /**
253
+ * Base class for Discord.js builder components
254
+ *
255
+ * Wraps Discord.js builders (SlashCommandBuilder, EmbedBuilder, etc.) with
256
+ * Seedcord-specific defaults and helper methods.
257
+ *
258
+ * @typeParam BuilderKey - The type of Discord.js builder being wrapped
259
+ */
260
+ var BuilderComponent = class extends BaseComponent {
261
+ type;
262
+ colorApplied = false;
263
+ constructor(type) {
264
+ const ComponentClass = BuilderTypes[type];
265
+ super(ComponentClass);
266
+ this.type = type;
267
+ if (this.instance instanceof SlashCommandBuilder || this.instance instanceof ContextMenuCommandBuilder) this.instance.setContexts(InteractionContextType.Guild);
268
+ }
269
+ get component() {
270
+ this.applyBotColor();
271
+ return this.instance;
272
+ }
273
+ applyBotColor() {
274
+ if (this.colorApplied) return;
275
+ this.colorApplied = true;
276
+ const color = getBotColor();
277
+ if (this.instance instanceof EmbedBuilder) {
278
+ if (this.instance.data.color === void 0) this.instance.setColor(resolveColor(color));
279
+ } else if (this.instance instanceof ContainerBuilder) {
280
+ const accent = this.instance.data.accent_color;
281
+ if (accent === null || accent === void 0) this.instance.setAccentColor(color === "Default" ? void 0 : resolveColor(color));
282
+ }
283
+ }
284
+ };
285
+ /**
286
+ * Base class for Discord action row components
287
+ *
288
+ * Wraps Discord.js action row builder with Seedcord-specific defaults and helper methods.
289
+ *
290
+ * @typeParam RowKey - The Discord.js action row type being wrapped
291
+ */
292
+ var RowComponent = class extends BaseComponent {
293
+ type;
294
+ constructor(type) {
295
+ const ComponentClass = RowTypes[type];
296
+ super(ComponentClass);
297
+ this.type = type;
298
+ }
299
+ get component() {
300
+ return this.instance;
301
+ }
302
+ };
303
+
304
+ //#endregion
305
+ //#region src/stops/NoticeCard.ts
306
+ /**
307
+ * Built fresh inside a {@link Notice}'s `render` to back its ComponentsV2 reply. The title renders as
308
+ * an h3 line with the description on the next line.
309
+ */
310
+ var NoticeCard = class extends BuilderComponent {
311
+ constructor(description, title = "Cannot Proceed") {
312
+ super("container");
313
+ this.instance.addTextDisplayComponents(new TextDisplayBuilder().setContent(`### ${title}\n${description}`));
314
+ }
315
+ };
316
+
317
+ //#endregion
318
+ //#region src/notices/index.ts
319
+ var GateNotice = class extends Notice {
320
+ render() {
321
+ return { components: [new NoticeCard(this.message).component] };
322
+ }
323
+ };
324
+ var NotOwner = class extends GateNotice {
325
+ constructor(message = "Only the bot owner can use this.") {
326
+ super(message);
327
+ }
328
+ };
329
+ var NotInGuild = class extends GateNotice {
330
+ constructor(message = "This can only be used in a server.") {
331
+ super(message);
332
+ }
333
+ };
334
+ var NotInDm = class extends GateNotice {
335
+ constructor(message = "This can only be used in a direct message.") {
336
+ super(message);
337
+ }
338
+ };
339
+ var OnCooldown = class extends GateNotice {
340
+ resetAt;
341
+ constructor(resetAt, message) {
342
+ super(message ?? `You are doing that too fast. Try again <t:${toEpochSeconds(resetAt)}:R>.`);
343
+ this.resetAt = resetAt;
344
+ }
345
+ };
346
+ var NotAllowed = class extends Notice {
347
+ constructor() {
348
+ super("not allowed");
349
+ }
350
+ render() {
351
+ return { components: [new NoticeCard("You are not allowed to use this command.").component] };
352
+ }
353
+ };
354
+ var NeedsAny = class extends Notice {
355
+ summaries;
356
+ constructor(summaries) {
357
+ super("not allowed");
358
+ this.summaries = summaries;
359
+ }
360
+ render() {
361
+ return { components: [new NoticeCard(`You need any of:\n${this.summaries.map((summary) => `• ${summary}`).join("\n")}`).component] };
362
+ }
363
+ };
364
+
365
+ //#endregion
366
+ //#region src/gates/effects.ts
367
+ const commitQueues = /* @__PURE__ */ new WeakMap();
368
+ function isEffectGate(gate) {
369
+ return typeof gate.commit === "function";
370
+ }
371
+ async function runCheck(gate, ctx) {
372
+ await gate.check(ctx);
373
+ if (!isEffectGate(gate)) return;
374
+ let queue = commitQueues.get(ctx);
375
+ if (!queue) {
376
+ queue = [];
377
+ commitQueues.set(ctx, queue);
378
+ }
379
+ queue.push(() => gate.commit(ctx));
380
+ }
381
+ async function runCommits(ctx) {
382
+ const queue = commitQueues.get(ctx);
383
+ if (!queue) return;
384
+ for (const commit of queue) await commit();
385
+ }
386
+ function discardCommits(ctx) {
387
+ commitQueues.delete(ctx);
388
+ }
389
+ function markCommits(ctx) {
390
+ return commitQueues.get(ctx)?.length ?? 0;
391
+ }
392
+ function rollbackCommits(ctx, mark) {
393
+ const queue = commitQueues.get(ctx);
394
+ if (queue && queue.length > mark) queue.length = mark;
395
+ }
396
+
397
+ //#endregion
398
+ //#region src/gates/catalog/options.ts
399
+ function pickNotice(options, makeDefault) {
400
+ return options?.notice ?? makeDefault(options?.message);
401
+ }
402
+
403
+ //#endregion
404
+ //#region src/customId/Errors.ts
405
+ /**
406
+ * Thrown when a customId was minted by an older version of its shape.
407
+ *
408
+ * This is normal after the shape changes. The reply tells the user to run the command again.
409
+ */
410
+ var StaleCustomId = class extends Notice {
411
+ constructor(prefix) {
412
+ super(`Stale customId for "${prefix}".`);
413
+ }
414
+ render() {
415
+ return { components: [new NoticeCard("This button or menu is from an older version. Please run the command again.", "Outdated").component] };
416
+ }
417
+ };
418
+ /**
419
+ * Thrown when a customId wire is corrupt or tampered with and cannot be trusted.
420
+ *
421
+ * This should not happen in normal use, so it reports.
422
+ */
423
+ var InvalidCustomId = class extends Notice {
424
+ constructor(detail) {
425
+ super(`Invalid customId. ${detail}`);
426
+ this.report = true;
427
+ }
428
+ render() {
429
+ return { components: [new NoticeCard("Something went wrong. Please try again.").component] };
430
+ }
431
+ };
432
+
433
+ //#endregion
434
+ //#region src/customId/codec.ts
435
+ const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
436
+ const BASE = 64n;
437
+ const CHAR_TO_VALUE = new Map([...ALPHABET].map((char, index) => [char, index]));
438
+ const DELIMITER = "";
439
+ const ESCAPE = "\x1B";
440
+ /** @internal */
441
+ const HASH_LENGTH = 3;
442
+ const SAFE_MAX = BigInt(Number.MAX_SAFE_INTEGER);
443
+ const SAFE_MIN = BigInt(Number.MIN_SAFE_INTEGER);
444
+ function bigintToBase64(value) {
445
+ if (value === 0n) return ALPHABET.charAt(0);
446
+ let text = "";
447
+ for (let remaining = value; remaining > 0n; remaining /= BASE) text = ALPHABET.charAt(Number(remaining % BASE)) + text;
448
+ return text;
449
+ }
450
+ function base64ToBigint(text) {
451
+ let value = 0n;
452
+ for (const char of text) {
453
+ const digit = CHAR_TO_VALUE.get(char);
454
+ if (digit === void 0) throw new InvalidCustomId(`bad character ${JSON.stringify(char)}`);
455
+ value = value * BASE + BigInt(digit);
456
+ }
457
+ return value;
458
+ }
459
+ function zigzagEncode(value) {
460
+ const big = BigInt(value);
461
+ return big >= 0n ? big << 1n : (-big << 1n) - 1n;
462
+ }
463
+ function zigzagDecode(encoded) {
464
+ return (encoded & 1n) === 1n ? -(encoded + 1n >> 1n) : encoded >> 1n;
465
+ }
466
+ function escapeToken(text) {
467
+ return text.replaceAll(/[\x1B\x1F]/g, (char) => ESCAPE + char);
468
+ }
469
+ function unescapeToken(text) {
470
+ let out = "";
471
+ for (let i = 0; i < text.length; i++) {
472
+ if (text.charAt(i) !== ESCAPE) {
473
+ out += text.charAt(i);
474
+ continue;
475
+ }
476
+ const next = text.charAt(i + 1);
477
+ if (next === "") throw new InvalidCustomId("dangling escape at end of token");
478
+ out += next;
479
+ i++;
480
+ }
481
+ return out;
482
+ }
483
+ function splitTokens(body) {
484
+ const pieces = [];
485
+ let current = "";
486
+ for (let i = 0; i < body.length; i++) {
487
+ const char = body.charAt(i);
488
+ if (char === ESCAPE) {
489
+ current += char + body.charAt(i + 1);
490
+ i++;
491
+ } else if (char === DELIMITER) {
492
+ pieces.push(current);
493
+ current = "";
494
+ } else current += char;
495
+ }
496
+ pieces.push(current);
497
+ return pieces;
498
+ }
499
+ function isBounded(field) {
500
+ if (field.kind === "int") return field.min !== void 0 && field.max !== void 0;
501
+ return field.kind === "snowflake" || field.kind === "uuid" || field.kind === "bool" || field.kind === "oneOf";
502
+ }
503
+ function radixOf(field) {
504
+ switch (field.kind) {
505
+ case "snowflake": return 1n << 64n;
506
+ case "uuid": return 1n << 128n;
507
+ case "bool": return 2n;
508
+ case "oneOf":
509
+ if (!field.choices?.length) throw new InvalidCustomId("oneOf field has no choices");
510
+ return BigInt(field.choices.length);
511
+ case "int":
512
+ if (field.min === void 0 || field.max === void 0) throw new InvalidCustomId("bounded int field is missing a bound");
513
+ return BigInt(field.max) - BigInt(field.min) + 1n;
514
+ default: throw new InvalidCustomId(`field kind ${field.kind} has no radix`);
515
+ }
516
+ }
517
+ function boundedToBigint(field, name, value) {
518
+ const slot = boundedSlot(field, name, value);
519
+ if (slot < 0n || slot >= radixOf(field)) outOfRange(name, value);
520
+ return slot;
521
+ }
522
+ function boundedSlot(field, name, value) {
523
+ switch (field.kind) {
524
+ case "snowflake":
525
+ if (typeof value !== "string" || !/^\d+$/.test(value)) return outOfRange(name, value);
526
+ return BigInt(value);
527
+ case "uuid": {
528
+ if (typeof value !== "string") return outOfRange(name, value);
529
+ const hex = value.replaceAll("-", "");
530
+ if (!/^[0-9a-fA-F]{32}$/.test(hex)) return outOfRange(name, value);
531
+ return BigInt(`0x${hex}`);
532
+ }
533
+ case "bool": return value ? 1n : 0n;
534
+ case "oneOf": {
535
+ const index = (field.choices ?? []).indexOf(value);
536
+ return index === -1 ? outOfRange(name, value) : BigInt(index);
537
+ }
538
+ case "int":
539
+ if (!Number.isInteger(value)) return outOfRange(name, value);
540
+ return BigInt(value - (field.min ?? 0));
541
+ default: return outOfRange(name, value);
542
+ }
543
+ }
544
+ function outOfRange(name, value) {
545
+ throw new SeedcordRangeError(SeedcordErrorCode.CustomIdValueOutOfRange, [name, String(value)]);
546
+ }
547
+ function bigintToBoundedValue(field, slot) {
548
+ switch (field.kind) {
549
+ case "snowflake": return slot.toString();
550
+ case "uuid": return bigintToUuid(slot);
551
+ case "bool": return slot === 1n;
552
+ case "oneOf": return (field.choices ?? [])[Number(slot)];
553
+ case "int": return Number(slot) + (field.min ?? 0);
554
+ default: throw new InvalidCustomId(`field kind ${field.kind} is not bounded`);
555
+ }
556
+ }
557
+ function bigintToUuid(value) {
558
+ const hex = value.toString(16).padStart(32, "0");
559
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
560
+ }
561
+ function encodeUnboundedToken(field, name, value) {
562
+ if (field.kind === "int") {
563
+ if (!Number.isSafeInteger(value)) outOfRange(name, value);
564
+ return bigintToBase64(zigzagEncode(value));
565
+ }
566
+ return escapeToken(value);
567
+ }
568
+ function decodeUnboundedToken(field, piece) {
569
+ if (field.kind !== "int") return unescapeToken(piece);
570
+ if (piece === "") throw new InvalidCustomId("empty integer token");
571
+ const decoded = zigzagDecode(base64ToBigint(piece));
572
+ if (decoded > SAFE_MAX || decoded < SAFE_MIN) throw new InvalidCustomId("integer out of safe range");
573
+ return Number(decoded);
574
+ }
575
+ /** @internal */
576
+ function computeLayoutHash(shape) {
577
+ const signature = JSON.stringify(Object.entries(shape).map(([name, field]) => [
578
+ name,
579
+ field.kind,
580
+ isBounded(field),
581
+ field.kind === "oneOf" ? field.choices ?? [] : null,
582
+ field.kind === "int" ? [field.min ?? null, field.max ?? null] : null
583
+ ]));
584
+ const modulus = BASE ** BigInt(3);
585
+ let hash = 0n;
586
+ for (const char of signature) hash = (hash * 131n + BigInt(char.charCodeAt(0))) % modulus;
587
+ let text = "";
588
+ for (let i = 0; i < 3; i++) {
589
+ text = ALPHABET.charAt(Number(hash % BASE)) + text;
590
+ hash /= BASE;
591
+ }
592
+ return text;
593
+ }
594
+ /** @internal */
595
+ function encodeBody(shape, values) {
596
+ const fields = Object.entries(shape);
597
+ const pieces = [];
598
+ const bounded = fields.filter(([, field]) => isBounded(field));
599
+ if (bounded.length > 0) {
600
+ let packed = 0n;
601
+ for (const [name, field] of bounded) packed = packed * radixOf(field) + boundedToBigint(field, name, values[name]);
602
+ pieces.push(bigintToBase64(packed));
603
+ }
604
+ for (const [name, field] of fields) if (!isBounded(field)) pieces.push(encodeUnboundedToken(field, name, values[name]));
605
+ return pieces.join(DELIMITER);
606
+ }
607
+ function unpackBounded(bounded, blob, result) {
608
+ if (blob === void 0 || blob === "") throw new InvalidCustomId("empty packed block");
609
+ let packed = base64ToBigint(blob);
610
+ for (const [name, field] of [...bounded].reverse()) {
611
+ const radix = radixOf(field);
612
+ result[name] = bigintToBoundedValue(field, packed % radix);
613
+ packed /= radix;
614
+ }
615
+ if (packed !== 0n) throw new InvalidCustomId("leftover bits after unpacking");
616
+ }
617
+ /** @internal */
618
+ function decodeBody(shape, body) {
619
+ const fields = Object.entries(shape);
620
+ const bounded = fields.filter(([, field]) => isBounded(field));
621
+ const unbounded = fields.filter(([, field]) => !isBounded(field));
622
+ const expected = (bounded.length > 0 ? 1 : 0) + unbounded.length;
623
+ if (expected === 0) {
624
+ if (body !== "") throw new InvalidCustomId(`expected an empty body, got ${JSON.stringify(body)}`);
625
+ return {};
626
+ }
627
+ const pieces = splitTokens(body);
628
+ if (pieces.length !== expected) throw new InvalidCustomId(`expected ${expected} piece(s), got ${pieces.length}`);
629
+ const result = {};
630
+ let cursor = 0;
631
+ if (bounded.length > 0) {
632
+ unpackBounded(bounded, pieces[cursor], result);
633
+ cursor++;
634
+ }
635
+ for (const [name, field] of unbounded) {
636
+ const piece = pieces[cursor];
637
+ cursor++;
638
+ if (piece === void 0) throw new InvalidCustomId("missing trailing piece");
639
+ result[name] = decodeUnboundedToken(field, piece);
640
+ }
641
+ return result;
642
+ }
643
+
644
+ //#endregion
645
+ //#region src/customId/CustomId.ts
646
+ const MAX_WIRE_LENGTH = 100;
647
+ function routeKeyOf(wire) {
648
+ const colon = wire.indexOf(":");
649
+ return colon === -1 ? "" : wire.slice(0, colon);
650
+ }
651
+ /** Strip the layout hash off the routeKey to recover the stable prefix the controller routes by. @internal */
652
+ function prefixOf(wire) {
653
+ const key = routeKeyOf(wire);
654
+ return key.length <= 3 ? "" : key.slice(0, key.length - 3);
655
+ }
656
+ /**
657
+ * A typed customId. The single source of truth shared by the component that mints it and the handler
658
+ * 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.
659
+ *
660
+ * @typeParam Prefix - The stable route prefix, e.g. 'approve'.
661
+ * @typeParam Shape - The accumulated fields, filled in by the chain.
662
+ *
663
+ * @example
664
+ * ```ts
665
+ * const ApproveId = new CustomId('approve')
666
+ * .snowflake('userId')
667
+ * .oneOf('action', ['approve', 'deny']);
668
+ *
669
+ * // Set the custom id on a button when creating it.
670
+ * new ButtonBuilder().setCustomId(ApproveId.encode({ userId: '123', action: 'approve' }));
671
+ *
672
+ * // reading in the handler: userId comes back a string
673
+ * const { userId, action } = this.params; // userId: string, action: 'approve' | 'deny'
674
+ * await this.event.guild?.members.fetch(userId);
675
+ * ```
676
+ */
677
+ var CustomId = class CustomId {
678
+ /** The stable route prefix, e.g. 'approve'. */
679
+ prefix;
680
+ /** Like a zod shape */
681
+ shape;
682
+ /** The prefix plus a short hash of the shape, the part of the wire before the colon. */
683
+ routeKey;
684
+ constructor(prefix, shape = {}) {
685
+ if (!prefix || /[:\x1B\x1F]/.test(prefix)) throw new SeedcordError(SeedcordErrorCode.CustomIdInvalidPrefix, [prefix]);
686
+ this.prefix = prefix;
687
+ this.shape = shape;
688
+ this.routeKey = prefix + computeLayoutHash(shape);
689
+ }
690
+ add(name, field) {
691
+ if (/^(?:0|[1-9]\d*)$/.test(name)) throw new SeedcordError(SeedcordErrorCode.CustomIdReservedFieldName, [name]);
692
+ if (name in this.shape) throw new SeedcordError(SeedcordErrorCode.CustomIdDuplicateFieldName, [name]);
693
+ const shape = {
694
+ ...this.shape,
695
+ [name]: field
696
+ };
697
+ return new CustomId(this.prefix, shape);
698
+ }
699
+ /**
700
+ * Add a Discord ID field, decoded as a string (the `Snowflake` type from discord-api-types).
701
+ *
702
+ * @example
703
+ * ```ts
704
+ * new CustomId('ban').snowflake('userId');
705
+ * ```
706
+ */
707
+ snowflake(name) {
708
+ return this.add(name, { kind: "snowflake" });
709
+ }
710
+ /**
711
+ * Add a UUID field, decoded as a lowercase uuid string.
712
+ *
713
+ * @example
714
+ * ```ts
715
+ * new CustomId('ticket').uuid('ticketId');
716
+ * ```
717
+ */
718
+ uuid(name) {
719
+ return this.add(name, { kind: "uuid" });
720
+ }
721
+ int(name, min, max) {
722
+ if (min !== void 0 && max !== void 0 && min > max) throw new SeedcordError(SeedcordErrorCode.CustomIdInvalidBounds, [
723
+ name,
724
+ min,
725
+ max
726
+ ]);
727
+ const field = min === void 0 || max === void 0 ? { kind: "int" } : {
728
+ kind: "int",
729
+ min,
730
+ max
731
+ };
732
+ return this.add(name, field);
733
+ }
734
+ /**
735
+ * Add a boolean flag.
736
+ *
737
+ * @example
738
+ * ```ts
739
+ * new CustomId('settings').bool('silent');
740
+ * ```
741
+ */
742
+ bool(name) {
743
+ return this.add(name, { kind: "bool" });
744
+ }
745
+ /**
746
+ * Add a field that is one value from a fixed list, decoded as the literal union. No `as const` needed.
747
+ *
748
+ * @example
749
+ * ```ts
750
+ * new CustomId('poll').oneOf('choice', ['yes', 'no', 'abstain']);
751
+ * ```
752
+ */
753
+ oneOf(name, choices) {
754
+ if (choices.length === 0) throw new SeedcordError(SeedcordErrorCode.CustomIdEmptyChoices, [name]);
755
+ return this.add(name, {
756
+ kind: "oneOf",
757
+ choices
758
+ });
759
+ }
760
+ /**
761
+ * Add a free short text field. Avoid it where possible, it cannot be packed so it costs the most wire space.
762
+ *
763
+ * @example
764
+ * ```ts
765
+ * new CustomId('note').str('message');
766
+ * ```
767
+ */
768
+ str(name) {
769
+ return this.add(name, { kind: "string" });
770
+ }
771
+ /**
772
+ * Mint a wire string from values. Throws if a value is out of its field's range or the wire is over 100 chars.
773
+ *
774
+ * @param values - One value per field, typed by the chain.
775
+ * @returns The wire string to put on the component's customId.
776
+ */
777
+ encode(values) {
778
+ const wire = `${this.routeKey}:${encodeBody(this.shape, values)}`;
779
+ if (wire.length > MAX_WIRE_LENGTH) throw new SeedcordRangeError(SeedcordErrorCode.CustomIdWireTooLong, [wire.length]);
780
+ return wire;
781
+ }
782
+ /**
783
+ * Read a wire string back into values.
784
+ *
785
+ * - This will refuse with a default Notice when the shape changed since the wire was minted.
786
+ * - Or refuse with a different default Notice on a corrupt or foreign wire.
787
+ *
788
+ * @param wire - The customId string from the interaction.
789
+ * @returns The decoded values, typed by the chain.
790
+ */
791
+ decode(wire) {
792
+ const key = routeKeyOf(wire);
793
+ if (key !== this.routeKey) {
794
+ if (prefixOf(wire) === this.prefix) throw new StaleCustomId(this.prefix);
795
+ throw new InvalidCustomId(`routeKey ${JSON.stringify(key)} is not ${JSON.stringify(this.routeKey)}`);
796
+ }
797
+ return decodeBody(this.shape, wire.slice(key.length + 1));
798
+ }
799
+ /** True if this wire was minted from this customId's prefix, ignoring the shape hash. */
800
+ owns(wire) {
801
+ return prefixOf(wire) === this.prefix;
802
+ }
803
+ };
804
+ /**
805
+ * Find the customId whose prefix owns this wire, decode against it, and report which one matched.
806
+ *
807
+ * @internal
808
+ */
809
+ function decodeFor(defs, wire) {
810
+ const match = defs.find((def) => def.owns(wire));
811
+ if (!match) throw new InvalidCustomId(`no customId owns ${JSON.stringify(routeKeyOf(wire))}`);
812
+ return {
813
+ prefix: match.prefix,
814
+ params: match.decode(wire)
815
+ };
816
+ }
817
+
818
+ //#endregion
819
+ export { EventMetadataKey as C, InteractionRoutes as D, InteractionRouteKeys as E, MiddlewareMetadataKey as O, CommandMetadataKey as S, InteractionMetadataKey as T, NoticeCard as _, discardCommits as a, setBotColor as b, runCheck as c, NeedsAny as d, NotAllowed as f, OnCooldown as g, NotOwner as h, pickNotice as i, SubscribeMetadataKey as k, runCommits as l, NotInGuild as m, decodeFor as n, markCommits as o, NotInDm as p, prefixOf as r, rollbackCommits as s, CustomId as t, GateNotice as u, BuilderComponent as v, GatedMetadataKey as w, Notice as x, RowComponent as y };
820
+ //# sourceMappingURL=CustomId-Bq6WagiA.mjs.map