spearkit 0.3.1 → 0.4.0

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/dist/index.cjs CHANGED
@@ -357,6 +357,58 @@ function discordTimestamp(date, style = "f") {
357
357
  function relativeTimestamp(date) {
358
358
  return discordTimestamp(date, "R");
359
359
  }
360
+ var MESSAGE_CHARACTER_LIMIT = 2e3;
361
+ function truncate(text, max, suffix = "\u2026") {
362
+ if (max <= 0) return "";
363
+ if (text.length <= max) return text;
364
+ if (suffix.length >= max) return suffix.slice(0, max);
365
+ return text.slice(0, max - suffix.length) + suffix;
366
+ }
367
+ function hardSplit(text, max) {
368
+ const out = [];
369
+ let rest = text;
370
+ while (rest.length > max) {
371
+ const space = rest.lastIndexOf(" ", max);
372
+ const cut = space > Math.floor(max / 2) ? space : max;
373
+ out.push(rest.slice(0, cut));
374
+ rest = rest.slice(cut).replace(/^ /, "");
375
+ }
376
+ if (rest.length > 0) out.push(rest);
377
+ return out;
378
+ }
379
+ function chunkMessage(text, options = {}) {
380
+ const max = options.max ?? MESSAGE_CHARACTER_LIMIT;
381
+ if (max <= 0) throw new RangeError("spearkit: chunkMessage max must be positive");
382
+ if (text.length === 0) return [];
383
+ if (text.length <= max) return [text];
384
+ const chunks = [];
385
+ let current = "";
386
+ const flush = () => {
387
+ if (current.length > 0) {
388
+ chunks.push(current);
389
+ current = "";
390
+ }
391
+ };
392
+ for (const line of text.split("\n")) {
393
+ if (line.length > max) {
394
+ flush();
395
+ const pieces = hardSplit(line, max);
396
+ for (let i = 0; i < pieces.length - 1; i++) chunks.push(pieces[i]);
397
+ current = pieces[pieces.length - 1];
398
+ continue;
399
+ }
400
+ const candidate = current.length > 0 ? `${current}
401
+ ${line}` : line;
402
+ if (candidate.length > max) {
403
+ flush();
404
+ current = line;
405
+ } else {
406
+ current = candidate;
407
+ }
408
+ }
409
+ flush();
410
+ return chunks;
411
+ }
360
412
 
361
413
  // src/cache.ts
362
414
  var MemoryCache = class {
@@ -451,6 +503,130 @@ function lookup(table, resourceName = "key") {
451
503
  function lookupOptional(table) {
452
504
  return (key) => table[key];
453
505
  }
506
+ function clone(value) {
507
+ if (value === void 0 || value === null) return value;
508
+ if (typeof structuredClone === "function") return structuredClone(value);
509
+ return JSON.parse(JSON.stringify(value));
510
+ }
511
+ var MemoryStore = class {
512
+ map = /* @__PURE__ */ new Map();
513
+ async get(key) {
514
+ return this.map.has(key) ? clone(this.map.get(key)) : void 0;
515
+ }
516
+ async set(key, value) {
517
+ this.map.set(key, clone(value));
518
+ }
519
+ async has(key) {
520
+ return this.map.has(key);
521
+ }
522
+ async delete(key) {
523
+ return this.map.delete(key);
524
+ }
525
+ async keys() {
526
+ return [...this.map.keys()];
527
+ }
528
+ async clear() {
529
+ this.map.clear();
530
+ }
531
+ };
532
+ var JsonStore = class {
533
+ constructor(path) {
534
+ this.path = path;
535
+ }
536
+ path;
537
+ cache = /* @__PURE__ */ new Map();
538
+ loading;
539
+ writeChain = Promise.resolve();
540
+ ensureLoaded() {
541
+ if (this.loading === void 0) this.loading = this.load();
542
+ return this.loading;
543
+ }
544
+ async load() {
545
+ try {
546
+ const raw = await promises.readFile(this.path, "utf8");
547
+ const parsed = JSON.parse(raw);
548
+ for (const [key, value] of Object.entries(parsed)) this.cache.set(key, value);
549
+ } catch (error) {
550
+ if (error.code !== "ENOENT") throw error;
551
+ }
552
+ }
553
+ /** Queue an atomic write of the current cache; serialised against prior writes. */
554
+ persist() {
555
+ this.writeChain = this.writeChain.then(async () => {
556
+ const body = JSON.stringify(Object.fromEntries(this.cache), null, 2);
557
+ await promises.mkdir(path.dirname(this.path), { recursive: true });
558
+ const tmp = `${this.path}.${process.pid}.${Date.now()}.tmp`;
559
+ await promises.writeFile(tmp, body, "utf8");
560
+ await promises.rename(tmp, this.path);
561
+ });
562
+ return this.writeChain;
563
+ }
564
+ async get(key) {
565
+ await this.ensureLoaded();
566
+ return this.cache.has(key) ? clone(this.cache.get(key)) : void 0;
567
+ }
568
+ async set(key, value) {
569
+ await this.ensureLoaded();
570
+ this.cache.set(key, clone(value));
571
+ await this.persist();
572
+ }
573
+ async has(key) {
574
+ await this.ensureLoaded();
575
+ return this.cache.has(key);
576
+ }
577
+ async delete(key) {
578
+ await this.ensureLoaded();
579
+ const existed = this.cache.delete(key);
580
+ if (existed) await this.persist();
581
+ return existed;
582
+ }
583
+ async keys() {
584
+ await this.ensureLoaded();
585
+ return [...this.cache.keys()];
586
+ }
587
+ async clear() {
588
+ await this.ensureLoaded();
589
+ this.cache.clear();
590
+ await this.persist();
591
+ }
592
+ };
593
+ function namespaced(store, prefix) {
594
+ const tag = `${prefix}:`;
595
+ return {
596
+ get: (key) => store.get(tag + key),
597
+ set: (key, value) => store.set(tag + key, value),
598
+ has: (key) => store.has(tag + key),
599
+ delete: (key) => store.delete(tag + key),
600
+ async keys() {
601
+ return (await store.keys()).filter((k) => k.startsWith(tag)).map((k) => k.slice(tag.length));
602
+ },
603
+ async clear() {
604
+ for (const key of await this.keys()) await store.delete(tag + key);
605
+ }
606
+ };
607
+ }
608
+ function createSettings(options) {
609
+ const { store, defaults } = options;
610
+ const ns = options.namespace ?? "settings";
611
+ const keyFor2 = (id) => `${ns}:${id}`;
612
+ return {
613
+ defaults,
614
+ store,
615
+ async get(id) {
616
+ const stored = await store.get(keyFor2(id));
617
+ return { ...defaults, ...stored ?? {} };
618
+ },
619
+ async set(id, patch) {
620
+ const stored = await store.get(keyFor2(id)) ?? {};
621
+ const merged = { ...stored, ...patch };
622
+ await store.set(keyFor2(id), merged);
623
+ return { ...defaults, ...merged };
624
+ },
625
+ async reset(id) {
626
+ await store.delete(keyFor2(id));
627
+ }
628
+ };
629
+ }
454
630
  function denied(reason) {
455
631
  return { allowed: false, reason };
456
632
  }
@@ -519,6 +695,278 @@ function requireBotPermissions(permission, reason = "I don't have permission to
519
695
  function guard(predicate) {
520
696
  return predicate;
521
697
  }
698
+ function missingPermissions(channel, who, required) {
699
+ const held = channel.permissionsFor(who);
700
+ if (held === null) return new discord_js.PermissionsBitField(required).toArray();
701
+ return held.missing(required);
702
+ }
703
+ function botMissingPermissions(channel, required) {
704
+ const me = channel.guild.members.me;
705
+ if (me === null) return new discord_js.PermissionsBitField(required).toArray();
706
+ return missingPermissions(channel, me, required);
707
+ }
708
+ function hasPermissions(channel, who, required) {
709
+ return missingPermissions(channel, who, required).length === 0;
710
+ }
711
+ function compareRoles(a, b) {
712
+ return a.roles.highest.comparePositionTo(b.roles.highest);
713
+ }
714
+ function canActOn(actor, target) {
715
+ if (actor.id === target.id) return false;
716
+ if (target.id === target.guild.ownerId) return false;
717
+ if (actor.id === actor.guild.ownerId) return true;
718
+ return compareRoles(actor, target) > 0;
719
+ }
720
+ function moderationCheck(options) {
721
+ const { moderator, target } = options;
722
+ const action = options.action ?? "moderate";
723
+ const me = options.me === void 0 ? target.guild.members.me : options.me;
724
+ const name = target.user.username;
725
+ if (moderator.id === target.id) return { ok: false, reason: `You can't ${action} yourself.` };
726
+ if (target.id === target.guild.ownerId) {
727
+ return { ok: false, reason: `You can't ${action} the server owner.` };
728
+ }
729
+ if (moderator.id !== moderator.guild.ownerId && compareRoles(moderator, target) <= 0) {
730
+ return {
731
+ ok: false,
732
+ reason: `You can't ${action} **${name}** \u2014 their highest role is above or equal to yours.`
733
+ };
734
+ }
735
+ if (me !== null) {
736
+ if (me.id === target.id) return { ok: false, reason: `I can't ${action} myself.` };
737
+ if (me.id !== me.guild.ownerId && compareRoles(me, target) <= 0) {
738
+ return {
739
+ ok: false,
740
+ reason: `I can't ${action} **${name}** \u2014 move my role above theirs and try again.`
741
+ };
742
+ }
743
+ }
744
+ return { ok: true };
745
+ }
746
+ var PERMISSION_LABELS = {
747
+ CreateInstantInvite: "Create Invite",
748
+ KickMembers: "Kick Members",
749
+ BanMembers: "Ban Members",
750
+ Administrator: "Administrator",
751
+ ManageChannels: "Manage Channels",
752
+ ManageGuild: "Manage Server",
753
+ AddReactions: "Add Reactions",
754
+ ViewAuditLog: "View Audit Log",
755
+ PrioritySpeaker: "Priority Speaker",
756
+ Stream: "Video",
757
+ ViewChannel: "View Channel",
758
+ SendMessages: "Send Messages",
759
+ SendTTSMessages: "Send TTS Messages",
760
+ ManageMessages: "Manage Messages",
761
+ EmbedLinks: "Embed Links",
762
+ AttachFiles: "Attach Files",
763
+ ReadMessageHistory: "Read Message History",
764
+ MentionEveryone: "Mention Everyone",
765
+ UseExternalEmojis: "Use External Emojis",
766
+ ViewGuildInsights: "View Server Insights",
767
+ Connect: "Connect",
768
+ Speak: "Speak",
769
+ MuteMembers: "Mute Members",
770
+ DeafenMembers: "Deafen Members",
771
+ MoveMembers: "Move Members",
772
+ UseVAD: "Use Voice Activity",
773
+ ChangeNickname: "Change Nickname",
774
+ ManageNicknames: "Manage Nicknames",
775
+ ManageRoles: "Manage Roles",
776
+ ManageWebhooks: "Manage Webhooks",
777
+ ManageGuildExpressions: "Manage Expressions",
778
+ UseApplicationCommands: "Use Application Commands",
779
+ RequestToSpeak: "Request to Speak",
780
+ ManageEvents: "Manage Events",
781
+ ManageThreads: "Manage Threads",
782
+ CreatePublicThreads: "Create Public Threads",
783
+ CreatePrivateThreads: "Create Private Threads",
784
+ UseExternalStickers: "Use External Stickers",
785
+ SendMessagesInThreads: "Send Messages in Threads",
786
+ UseEmbeddedActivities: "Use Activities",
787
+ ModerateMembers: "Timeout Members"
788
+ };
789
+ function formatPermissions(permissions) {
790
+ const names = new discord_js.PermissionsBitField(permissions).toArray();
791
+ if (names.length === 0) return "none";
792
+ return names.map((flag) => PERMISSION_LABELS[flag] ?? flag).join(", ");
793
+ }
794
+ var DiscordErrorCode = {
795
+ /** A referenced channel no longer exists or is invisible to the bot. */
796
+ UnknownChannel: discord_js.RESTJSONErrorCodes.UnknownChannel,
797
+ // 10003
798
+ /** The targeted guild is gone or the bot was removed from it. */
799
+ UnknownGuild: discord_js.RESTJSONErrorCodes.UnknownGuild,
800
+ // 10004
801
+ /** The referenced member is not in the guild. */
802
+ UnknownMember: discord_js.RESTJSONErrorCodes.UnknownMember,
803
+ // 10007
804
+ /** The message was deleted (or never existed) before the action ran. */
805
+ UnknownMessage: discord_js.RESTJSONErrorCodes.UnknownMessage,
806
+ // 10008
807
+ /** The user could not be resolved. */
808
+ UnknownUser: discord_js.RESTJSONErrorCodes.UnknownUser,
809
+ // 10013
810
+ /** The interaction token expired (the classic 3-second-window failure). */
811
+ UnknownInteraction: discord_js.RESTJSONErrorCodes.UnknownInteraction,
812
+ // 10062
813
+ /** The bot lacks access to the resource entirely (not just one permission). */
814
+ MissingAccess: discord_js.RESTJSONErrorCodes.MissingAccess,
815
+ // 50001
816
+ /** Action attempted on a DM channel that does not support it. */
817
+ CannotExecuteActionOnDMChannel: discord_js.RESTJSONErrorCodes.CannotExecuteActionOnDMChannel,
818
+ // 50003
819
+ /** The target user has DMs closed or blocked the bot. */
820
+ CannotSendMessagesToThisUser: discord_js.RESTJSONErrorCodes.CannotSendMessagesToThisUser,
821
+ // 50007
822
+ /** The bot is missing one or more permissions required for the action. */
823
+ MissingPermissions: discord_js.RESTJSONErrorCodes.MissingPermissions,
824
+ // 50013
825
+ /** The request body failed Discord's validation. */
826
+ InvalidFormBodyOrContentType: discord_js.RESTJSONErrorCodes.InvalidFormBodyOrContentType,
827
+ // 50035
828
+ /** The interaction was already acknowledged elsewhere. */
829
+ InteractionHasAlreadyBeenAcknowledged: discord_js.RESTJSONErrorCodes.InteractionHasAlreadyBeenAcknowledged,
830
+ // 40060
831
+ /** The bot reached the maximum number of guilds it may join. */
832
+ MaximumNumberOfGuildsReached: discord_js.RESTJSONErrorCodes.MaximumNumberOfGuildsReached,
833
+ // 30001
834
+ /** Too many active reactions / pins / etc. of this kind. */
835
+ MaximumNumberOfReactionsReached: discord_js.RESTJSONErrorCodes.MaximumNumberOfReactionsReached
836
+ // 30010
837
+ };
838
+ function isDiscordError(error, code) {
839
+ if (!(error instanceof discord_js.DiscordAPIError)) return false;
840
+ if (code === void 0) return true;
841
+ const codes = Array.isArray(code) ? code : [code];
842
+ return codes.includes(error.code);
843
+ }
844
+ function isHTTPError(error) {
845
+ return error instanceof discord_js.HTTPError;
846
+ }
847
+ function isRateLimitError(error) {
848
+ return error instanceof discord_js.DiscordAPIError && error.status === 429;
849
+ }
850
+ var FRIENDLY = {
851
+ [DiscordErrorCode.UnknownChannel]: "That channel no longer exists.",
852
+ [DiscordErrorCode.UnknownMessage]: "That message no longer exists.",
853
+ [DiscordErrorCode.UnknownMember]: "That member isn't in this server.",
854
+ [DiscordErrorCode.UnknownUser]: "I couldn't find that user.",
855
+ [DiscordErrorCode.UnknownInteraction]: "This took too long and expired \u2014 please run it again.",
856
+ [DiscordErrorCode.InteractionHasAlreadyBeenAcknowledged]: "This took too long and expired \u2014 please run it again.",
857
+ [DiscordErrorCode.MissingAccess]: "I don't have access to do that here.",
858
+ [DiscordErrorCode.CannotSendMessagesToThisUser]: "I can't DM that user \u2014 they may have DMs disabled.",
859
+ [DiscordErrorCode.CannotExecuteActionOnDMChannel]: "That can't be done in a DM.",
860
+ [DiscordErrorCode.MissingPermissions]: "I'm missing the permissions needed to do that."
861
+ };
862
+ function explainDiscordError(error) {
863
+ if (isRateLimitError(error)) {
864
+ return "I'm being rate-limited right now \u2014 please try again in a moment.";
865
+ }
866
+ if (!(error instanceof discord_js.DiscordAPIError)) return null;
867
+ if (typeof error.code === "number") {
868
+ const known = FRIENDLY[error.code];
869
+ if (known !== void 0) return known;
870
+ }
871
+ return null;
872
+ }
873
+
874
+ // src/shutdown.ts
875
+ function gracefulShutdown(client, options = {}) {
876
+ const signals = options.signals ?? ["SIGINT", "SIGTERM"];
877
+ const timeoutMs = options.timeoutMs ?? 1e4;
878
+ const exit = options.exit ?? true;
879
+ let shuttingDown = false;
880
+ const handler = (signal) => {
881
+ if (shuttingDown) return;
882
+ shuttingDown = true;
883
+ options.logger?.info?.(`received ${signal}, shutting down`);
884
+ const force = setTimeout(() => {
885
+ options.logger?.error?.("shutdown timed out \u2014 forcing exit");
886
+ if (exit) process.exit(1);
887
+ }, timeoutMs);
888
+ if (typeof force.unref === "function") force.unref();
889
+ void (async () => {
890
+ try {
891
+ await options.onShutdown?.(signal);
892
+ await client.destroy();
893
+ clearTimeout(force);
894
+ options.logger?.info?.("shutdown complete");
895
+ if (exit) process.exit(0);
896
+ } catch (error) {
897
+ clearTimeout(force);
898
+ options.logger?.error?.("shutdown failed", error);
899
+ if (exit) process.exit(1);
900
+ }
901
+ })();
902
+ };
903
+ for (const signal of signals) process.on(signal, handler);
904
+ return () => {
905
+ for (const signal of signals) process.off(signal, handler);
906
+ };
907
+ }
908
+
909
+ // src/collectors.ts
910
+ async function awaitMessage(channel, options = {}) {
911
+ const { filter, time = 6e4 } = options;
912
+ try {
913
+ const collected = await channel.awaitMessages({
914
+ filter,
915
+ max: 1,
916
+ time,
917
+ errors: ["time"]
918
+ });
919
+ return collected.first() ?? null;
920
+ } catch {
921
+ return null;
922
+ }
923
+ }
924
+ async function awaitComponent(message, options = {}) {
925
+ const { filter, time = 6e4, componentType } = options;
926
+ try {
927
+ return await message.awaitMessageComponent({
928
+ time,
929
+ filter: (interaction) => (componentType === void 0 || interaction.componentType === componentType) && (filter?.(interaction) ?? true)
930
+ });
931
+ } catch {
932
+ return null;
933
+ }
934
+ }
935
+ function resolveModalCustomId(modal2) {
936
+ const m = modal2;
937
+ return m.data?.custom_id ?? m.custom_id ?? m.customId;
938
+ }
939
+ async function showAndAwaitModal(interaction, modal2, options = {}) {
940
+ const customId = resolveModalCustomId(modal2);
941
+ await interaction.showModal(modal2);
942
+ const { time = 12e4, filter } = options;
943
+ try {
944
+ return await interaction.awaitModalSubmit({
945
+ time,
946
+ filter: (submitted) => submitted.user.id === interaction.user.id && (customId === void 0 || submitted.customId === customId) && (filter?.(submitted) ?? true)
947
+ });
948
+ } catch {
949
+ return null;
950
+ }
951
+ }
952
+ var DEFAULT_AUTO_DEFER_DELAY_MS = 2e3;
953
+ function normalizeAutoDefer(input) {
954
+ if (input === void 0 || input === false) return void 0;
955
+ if (input === true) return { ephemeral: false, delayMs: DEFAULT_AUTO_DEFER_DELAY_MS };
956
+ return {
957
+ ephemeral: input.ephemeral ?? false,
958
+ delayMs: input.delayMs ?? DEFAULT_AUTO_DEFER_DELAY_MS
959
+ };
960
+ }
961
+ function armAutoDefer(interaction, config) {
962
+ const timer = setTimeout(() => {
963
+ if (!interaction.replied && !interaction.deferred) {
964
+ void interaction.deferReply(config.ephemeral ? { flags: discord_js.MessageFlags.Ephemeral } : {}).catch(() => void 0);
965
+ }
966
+ }, config.delayMs);
967
+ if (typeof timer.unref === "function") timer.unref();
968
+ return () => clearTimeout(timer);
969
+ }
522
970
  function withEphemeralFlag(flags) {
523
971
  if (flags == null) return discord_js.MessageFlags.Ephemeral;
524
972
  if (typeof flags === "number" || typeof flags === "bigint") {
@@ -615,6 +1063,38 @@ var BaseContext = class {
615
1063
  await this.reply(input);
616
1064
  }
617
1065
  }
1066
+ /** The bot's resolved permissions in the current channel. */
1067
+ get botPermissions() {
1068
+ return this.interaction.appPermissions;
1069
+ }
1070
+ /**
1071
+ * Permission flag names the BOT is missing in the current channel — empty when
1072
+ * it has them all. Zero-fetch: reads the permissions Discord attached to the
1073
+ * interaction. Use before an action that needs elevated permissions.
1074
+ */
1075
+ botMissing(required) {
1076
+ return this.interaction.appPermissions.missing(required);
1077
+ }
1078
+ /** Permission flag names the invoking USER is missing in the current channel. */
1079
+ userMissing(required) {
1080
+ const held = this.interaction.memberPermissions;
1081
+ if (held === null) return new discord_js.PermissionsBitField(required).toArray();
1082
+ return held.missing(required);
1083
+ }
1084
+ /**
1085
+ * Wait for the next message in this channel from `userId` (defaults to the
1086
+ * invoking user), resolving to it or `null` on timeout. The "type your answer"
1087
+ * flow without hand-rolling a collector.
1088
+ */
1089
+ awaitMessageFrom(userId = this.user.id, options = {}) {
1090
+ const channel = this.interaction.channel;
1091
+ if (channel === null || !("awaitMessages" in channel)) return Promise.resolve(null);
1092
+ const extra = options.filter;
1093
+ return awaitMessage(channel, {
1094
+ time: options.time,
1095
+ filter: (message) => message.author.id === userId && (extra?.(message) ?? true)
1096
+ });
1097
+ }
618
1098
  /** Get the configured {@link Embeds} factory — `client.embeds` or the default. */
619
1099
  getEmbeds() {
620
1100
  return this.interaction.client.embeds ?? defaultEmbeds;
@@ -779,6 +1259,7 @@ function userCommand(config) {
779
1259
  name: config.name,
780
1260
  cooldown,
781
1261
  guards: config.guards,
1262
+ autoDefer: normalizeAutoDefer(config.autoDefer),
782
1263
  toJSON: () => baseJSON(config, discord_js.ApplicationCommandType.User),
783
1264
  execute: async (interaction) => {
784
1265
  await config.run(new UserContextMenuContext(interaction));
@@ -792,6 +1273,7 @@ function messageCommand(config) {
792
1273
  name: config.name,
793
1274
  cooldown,
794
1275
  guards: config.guards,
1276
+ autoDefer: normalizeAutoDefer(config.autoDefer),
795
1277
  toJSON: () => baseJSON(config, discord_js.ApplicationCommandType.Message),
796
1278
  execute: async (interaction) => {
797
1279
  await config.run(new MessageContextMenuContext(interaction));
@@ -806,6 +1288,7 @@ var ContextMenuRegistry = class {
806
1288
  defaultCooldown;
807
1289
  defaultGuards = [];
808
1290
  onUsage;
1291
+ defaultAutoDefer;
809
1292
  /** Register one or more context-menu commands. */
810
1293
  add(...commands) {
811
1294
  for (const command2 of commands) {
@@ -839,6 +1322,11 @@ var ContextMenuRegistry = class {
839
1322
  this.defaultGuards = guards;
840
1323
  return this;
841
1324
  }
1325
+ /** Default auto-defer applied to menus that don't set their own. */
1326
+ setAutoDefer(config) {
1327
+ this.defaultAutoDefer = config;
1328
+ return this;
1329
+ }
842
1330
  setUsageHook(hook) {
843
1331
  this.onUsage = hook;
844
1332
  return this;
@@ -882,6 +1370,8 @@ var ContextMenuRegistry = class {
882
1370
  return;
883
1371
  }
884
1372
  }
1373
+ const autoDefer = command2.autoDefer ?? this.defaultAutoDefer;
1374
+ const cancelAutoDefer = autoDefer !== void 0 ? armAutoDefer(interaction, autoDefer) : void 0;
885
1375
  const start = Date.now();
886
1376
  try {
887
1377
  if (command2.kind === "userMenu") {
@@ -917,15 +1407,19 @@ var ContextMenuRegistry = class {
917
1407
  timestamp: /* @__PURE__ */ new Date()
918
1408
  });
919
1409
  interaction.client.emit("error", err);
1410
+ const content = explainDiscordError(err) ?? "Something went wrong.";
920
1411
  try {
921
- if (!interaction.replied && !interaction.deferred) {
922
- await interaction.reply({
923
- content: "Something went wrong.",
924
- flags: discord_js.MessageFlags.Ephemeral
925
- });
1412
+ if (interaction.deferred) {
1413
+ await interaction.editReply({ content });
1414
+ } else if (interaction.replied) {
1415
+ await interaction.followUp({ content, flags: discord_js.MessageFlags.Ephemeral });
1416
+ } else {
1417
+ await interaction.reply({ content, flags: discord_js.MessageFlags.Ephemeral });
926
1418
  }
927
1419
  } catch {
928
1420
  }
1421
+ } finally {
1422
+ cancelAutoDefer?.();
929
1423
  }
930
1424
  }
931
1425
  };
@@ -1861,7 +2355,8 @@ function resolveOptions(input) {
1861
2355
  prefixes: typeof prefix === "string" ? [prefix] : [...prefix],
1862
2356
  mention: options.mention ?? true,
1863
2357
  ignoreBots: options.ignoreBots ?? true,
1864
- caseInsensitive: options.caseInsensitive ?? true
2358
+ caseInsensitive: options.caseInsensitive ?? true,
2359
+ dynamic: options.dynamic
1865
2360
  };
1866
2361
  }
1867
2362
  function actorFromMessage(message) {
@@ -1945,8 +2440,8 @@ var PrefixRegistry = class {
1945
2440
  return [...this.commands.values()];
1946
2441
  }
1947
2442
  /** Strip a matching prefix (or bot mention) from `content`, or return `null`. */
1948
- stripPrefix(content, botId) {
1949
- for (const prefix of this.options.prefixes) {
2443
+ stripPrefix(content, botId, prefixes) {
2444
+ for (const prefix of prefixes) {
1950
2445
  if (prefix.length > 0 && content.startsWith(prefix)) return content.slice(prefix.length);
1951
2446
  }
1952
2447
  if (this.options.mention && botId !== void 0) {
@@ -1955,14 +2450,24 @@ var PrefixRegistry = class {
1955
2450
  }
1956
2451
  return null;
1957
2452
  }
2453
+ /** Resolve the effective prefixes for a message: static plus any dynamic ones. */
2454
+ async resolvePrefixes(message) {
2455
+ if (this.options.dynamic === void 0) return this.options.prefixes;
2456
+ const extra = await this.options.dynamic(message);
2457
+ if (extra === null || extra === void 0) return this.options.prefixes;
2458
+ const list = typeof extra === "string" ? [extra] : extra;
2459
+ return [...this.options.prefixes, ...list];
2460
+ }
1958
2461
  /**
1959
2462
  * Parse and dispatch a message. Returns `true` when a command ran (or was
1960
2463
  * blocked by a cooldown), `false` when the message was not a prefix command.
1961
2464
  */
1962
2465
  async handle(message) {
1963
- if (this.options.prefixes.length === 0 && !this.options.mention) return false;
2466
+ const hasMatcher = this.options.prefixes.length > 0 || this.options.mention || this.options.dynamic !== void 0;
2467
+ if (!hasMatcher) return false;
1964
2468
  if (this.options.ignoreBots && message.author.bot) return false;
1965
- const stripped = this.stripPrefix(message.content, message.client.user?.id);
2469
+ const prefixes = await this.resolvePrefixes(message);
2470
+ const stripped = this.stripPrefix(message.content, message.client.user?.id, prefixes);
1966
2471
  if (stripped === null) return false;
1967
2472
  const trimmed = stripped.trimStart();
1968
2473
  const match = /^(\S+)\s*([\s\S]*)$/.exec(trimmed);
@@ -2310,6 +2815,13 @@ var CommandContext = class extends BaseContext {
2310
2815
  async showModal(modal2) {
2311
2816
  await this.interaction.showModal(modal2);
2312
2817
  }
2818
+ /**
2819
+ * Show a modal and wait for the user to submit it, resolving to the submission
2820
+ * or `null` if they dismiss it / it times out. Scoped to this user and modal.
2821
+ */
2822
+ awaitModal(modal2, options) {
2823
+ return showAndAwaitModal(this.interaction, modal2, options);
2824
+ }
2313
2825
  };
2314
2826
  var AutocompleteContext = class {
2315
2827
  constructor(interaction) {
@@ -2364,6 +2876,8 @@ var SlashCommand = class {
2364
2876
  cooldown;
2365
2877
  /** Resolved guard list for this command, if any. */
2366
2878
  guards;
2879
+ /** Resolved auto-defer configuration for this command, if any. */
2880
+ autoDefer;
2367
2881
  /** @internal */
2368
2882
  constructor(spec) {
2369
2883
  this.name = spec.name;
@@ -2373,6 +2887,7 @@ var SlashCommand = class {
2373
2887
  this.autocompleter = spec.autocompleter;
2374
2888
  this.cooldown = spec.cooldown;
2375
2889
  this.guards = spec.guards;
2890
+ this.autoDefer = spec.autoDefer;
2376
2891
  }
2377
2892
  /** Serialise to the discord REST chat-input command payload. */
2378
2893
  toJSON() {
@@ -2452,7 +2967,8 @@ function command(config) {
2452
2967
  executor,
2453
2968
  autocompleter: makeAutocompleter(options),
2454
2969
  cooldown: config.cooldown !== void 0 ? normalizeCooldown(config.cooldown) : void 0,
2455
- guards: config.guards
2970
+ guards: config.guards,
2971
+ autoDefer: normalizeAutoDefer(config.autoDefer)
2456
2972
  });
2457
2973
  }
2458
2974
  function subcommand(config) {
@@ -2525,7 +3041,8 @@ function commandGroup(config) {
2525
3041
  executor,
2526
3042
  autocompleter,
2527
3043
  cooldown: config.cooldown !== void 0 ? normalizeCooldown(config.cooldown) : void 0,
2528
- guards: config.guards
3044
+ guards: config.guards,
3045
+ autoDefer: normalizeAutoDefer(config.autoDefer)
2529
3046
  });
2530
3047
  }
2531
3048
  var CommandRegistry = class {
@@ -2536,6 +3053,7 @@ var CommandRegistry = class {
2536
3053
  defaultCooldown;
2537
3054
  defaultGuards = [];
2538
3055
  onUsage;
3056
+ defaultAutoDefer;
2539
3057
  /** Register one or more commands. Later registrations override by name. */
2540
3058
  add(...commands) {
2541
3059
  for (const command2 of commands) this.commands.set(command2.name, command2);
@@ -2582,6 +3100,11 @@ var CommandRegistry = class {
2582
3100
  this.defaultGuards = guards;
2583
3101
  return this;
2584
3102
  }
3103
+ /** Default auto-defer applied to commands that don't set their own. */
3104
+ setAutoDefer(config) {
3105
+ this.defaultAutoDefer = config;
3106
+ return this;
3107
+ }
2585
3108
  /** Attach a hook called after each successful command execution. */
2586
3109
  setUsageHook(hook) {
2587
3110
  this.onUsage = hook;
@@ -2621,6 +3144,8 @@ var CommandRegistry = class {
2621
3144
  return;
2622
3145
  }
2623
3146
  }
3147
+ const autoDefer = command2.autoDefer ?? this.defaultAutoDefer;
3148
+ const cancelAutoDefer = autoDefer !== void 0 ? armAutoDefer(interaction, autoDefer) : void 0;
2624
3149
  const start = Date.now();
2625
3150
  try {
2626
3151
  await command2.execute(interaction);
@@ -2654,6 +3179,8 @@ var CommandRegistry = class {
2654
3179
  } else {
2655
3180
  await this.defaultErrorReply(err, interaction);
2656
3181
  }
3182
+ } finally {
3183
+ cancelAutoDefer?.();
2657
3184
  }
2658
3185
  }
2659
3186
  /** Dispatch an autocomplete interaction to its command. */
@@ -2686,7 +3213,7 @@ var CommandRegistry = class {
2686
3213
  }
2687
3214
  async defaultErrorReply(error, interaction) {
2688
3215
  interaction.client.emit("error", error);
2689
- const content = "Something went wrong while running that command.";
3216
+ const content = explainDiscordError(error) ?? "Something went wrong while running that command.";
2690
3217
  try {
2691
3218
  if (interaction.deferred) {
2692
3219
  await interaction.editReply({ content });
@@ -2902,6 +3429,13 @@ var MessageComponentContext = class extends BaseContext {
2902
3429
  async showModal(modal2) {
2903
3430
  await this.interaction.showModal(modal2);
2904
3431
  }
3432
+ /**
3433
+ * Show a modal and wait for the user to submit it, resolving to the submission
3434
+ * or `null` if they dismiss it / it times out. Scoped to this user and modal.
3435
+ */
3436
+ awaitModal(modal2, options) {
3437
+ return showAndAwaitModal(this.interaction, modal2, options);
3438
+ }
2905
3439
  };
2906
3440
  var ButtonContext = class extends MessageComponentContext {
2907
3441
  };
@@ -3113,8 +3647,13 @@ var ComponentRegistry = class {
3113
3647
  await this.errorHandler(err, interaction);
3114
3648
  } else {
3115
3649
  interaction.client.emit("error", err);
3116
- if (!interaction.replied && !interaction.deferred) {
3117
- await interaction.reply({ content: "Something went wrong.", flags: discord_js.MessageFlags.Ephemeral }).catch(() => void 0);
3650
+ const content = explainDiscordError(err) ?? "Something went wrong.";
3651
+ if (interaction.deferred) {
3652
+ await interaction.editReply({ content }).catch(() => void 0);
3653
+ } else if (interaction.replied) {
3654
+ await interaction.followUp({ content, flags: discord_js.MessageFlags.Ephemeral }).catch(() => void 0);
3655
+ } else {
3656
+ await interaction.reply({ content, flags: discord_js.MessageFlags.Ephemeral }).catch(() => void 0);
3118
3657
  }
3119
3658
  }
3120
3659
  }
@@ -3416,17 +3955,20 @@ var SpearClient = class extends discord_js.Client {
3416
3955
  contextMenus = new ContextMenuRegistry();
3417
3956
  envConfig;
3418
3957
  constructor(options = {}) {
3419
- const { intents, logger, dotenv, cooldown, prefix, usage, embeds, guards, ...rest } = options;
3958
+ const { intents, logger, dotenv, cooldown, prefix, usage, embeds, guards, autoDefer, ...rest } = options;
3420
3959
  super({ ...rest, intents: intents ?? Intents.default });
3421
3960
  this.embeds = embeds instanceof Embeds ? embeds : new Embeds(embeds);
3422
3961
  this.envConfig = dotenv === false ? false : dotenv === void 0 || dotenv === true ? {} : dotenv;
3423
3962
  this.logger = logger instanceof Logger ? logger : new Logger(logger);
3424
3963
  const defaultCooldown = cooldown !== void 0 ? normalizeCooldown(cooldown) : void 0;
3964
+ const defaultAutoDefer = normalizeAutoDefer(autoDefer);
3425
3965
  this.commands.setLogger(this.logger.child("commands"));
3426
3966
  this.commands.setCooldowns(this.cooldowns, defaultCooldown);
3967
+ this.commands.setAutoDefer(defaultAutoDefer);
3427
3968
  this.components.setLogger(this.logger.child("components"));
3428
3969
  this.contextMenus.setLogger(this.logger.child("contextMenus"));
3429
3970
  this.contextMenus.setCooldowns(this.cooldowns, defaultCooldown);
3971
+ this.contextMenus.setAutoDefer(defaultAutoDefer);
3430
3972
  this.prefix.setLogger(this.logger.child("prefix"));
3431
3973
  this.prefix.setCooldowns(this.cooldowns, defaultCooldown);
3432
3974
  if (prefix !== void 0) this.prefix.setOptions(prefix);
@@ -3560,6 +4102,21 @@ var SpearClient = class extends discord_js.Client {
3560
4102
  this.scheduler.stop();
3561
4103
  await super.destroy();
3562
4104
  }
4105
+ /**
4106
+ * Close the bot cleanly on `SIGINT`/`SIGTERM`: run an optional hook, then
4107
+ * `destroy()` (stopping the scheduler and gateway), then exit. Returns a
4108
+ * disposer that removes the signal handlers. Logs progress via `client.logger`.
4109
+ */
4110
+ enableGracefulShutdown(options = {}) {
4111
+ const log = this.logger.child("shutdown");
4112
+ return gracefulShutdown(this, {
4113
+ logger: {
4114
+ info: (message) => log.info(message),
4115
+ error: (message, meta) => log.error(message, { error: toError(meta) })
4116
+ },
4117
+ ...options
4118
+ });
4119
+ }
3563
4120
  async route(interaction) {
3564
4121
  if (interaction.isChatInputCommand()) {
3565
4122
  await this.commands.handle(interaction);
@@ -3630,16 +4187,21 @@ exports.ComponentRegistry = ComponentRegistry;
3630
4187
  exports.ContextMenuRegistry = ContextMenuRegistry;
3631
4188
  exports.CooldownManager = CooldownManager;
3632
4189
  exports.CronExpression = CronExpression;
4190
+ exports.DEFAULT_AUTO_DEFER_DELAY_MS = DEFAULT_AUTO_DEFER_DELAY_MS;
3633
4191
  exports.DEFAULT_EMBED_COLORS = DEFAULT_EMBED_COLORS;
3634
4192
  exports.DEFAULT_EMBED_ICONS = DEFAULT_EMBED_ICONS;
4193
+ exports.DiscordErrorCode = DiscordErrorCode;
3635
4194
  exports.Embeds = Embeds;
3636
4195
  exports.EventRegistry = EventRegistry;
3637
4196
  exports.Intents = Intents;
3638
4197
  exports.JsonFileUsageStore = JsonFileUsageStore;
4198
+ exports.JsonStore = JsonStore;
3639
4199
  exports.KeyedLock = KeyedLock;
3640
4200
  exports.Logger = Logger;
3641
4201
  exports.MAX_CUSTOM_ID_LENGTH = MAX_CUSTOM_ID_LENGTH;
4202
+ exports.MESSAGE_CHARACTER_LIMIT = MESSAGE_CHARACTER_LIMIT;
3642
4203
  exports.MemoryCache = MemoryCache;
4204
+ exports.MemoryStore = MemoryStore;
3643
4205
  exports.MemoryUsageStore = MemoryUsageStore;
3644
4206
  exports.MentionableSelectContext = MentionableSelectContext;
3645
4207
  exports.MessageComponentContext = MessageComponentContext;
@@ -3656,18 +4218,26 @@ exports.TaskScheduler = TaskScheduler;
3656
4218
  exports.UsageTracker = UsageTracker;
3657
4219
  exports.UserContextMenuContext = UserContextMenuContext;
3658
4220
  exports.UserSelectContext = UserSelectContext;
4221
+ exports.armAutoDefer = armAutoDefer;
3659
4222
  exports.asEphemeral = asEphemeral;
4223
+ exports.awaitComponent = awaitComponent;
4224
+ exports.awaitMessage = awaitMessage;
4225
+ exports.botMissingPermissions = botMissingPermissions;
3660
4226
  exports.buildCustomId = buildCustomId;
3661
4227
  exports.buildPaginatorPage = buildPaginatorPage;
3662
4228
  exports.button = button;
4229
+ exports.canActOn = canActOn;
3663
4230
  exports.channelSelect = channelSelect;
4231
+ exports.chunkMessage = chunkMessage;
3664
4232
  exports.collectModules = collectModules;
3665
4233
  exports.command = command;
3666
4234
  exports.commandGroup = commandGroup;
4235
+ exports.compareRoles = compareRoles;
3667
4236
  exports.compilePattern = compilePattern;
3668
4237
  exports.confirm = confirm;
3669
4238
  exports.consoleSink = consoleSink;
3670
4239
  exports.createCache = createCache;
4240
+ exports.createSettings = createSettings;
3671
4241
  exports.cron = cron;
3672
4242
  exports.defaultEmbeds = defaultEmbeds;
3673
4243
  exports.definePlugin = definePlugin;
@@ -3677,6 +4247,7 @@ exports.dmOnly = dmOnly;
3677
4247
  exports.effectiveDuration = effectiveDuration;
3678
4248
  exports.env = env;
3679
4249
  exports.event = event;
4250
+ exports.explainDiscordError = explainDiscordError;
3680
4251
  exports.fetchChannel = fetchChannel;
3681
4252
  exports.fetchGuild = fetchGuild;
3682
4253
  exports.fetchMember = fetchMember;
@@ -3685,9 +4256,15 @@ exports.fetchRole = fetchRole;
3685
4256
  exports.fetchUser = fetchUser;
3686
4257
  exports.formatCooldownMessage = formatCooldownMessage;
3687
4258
  exports.formatDuration = formatDuration;
4259
+ exports.formatPermissions = formatPermissions;
3688
4260
  exports.formatUsage = formatUsage;
4261
+ exports.gracefulShutdown = gracefulShutdown;
3689
4262
  exports.guard = guard;
3690
4263
  exports.guildOnly = guildOnly;
4264
+ exports.hasPermissions = hasPermissions;
4265
+ exports.isDiscordError = isDiscordError;
4266
+ exports.isHTTPError = isHTTPError;
4267
+ exports.isRateLimitError = isRateLimitError;
3691
4268
  exports.jsonlSink = jsonlSink;
3692
4269
  exports.linkButton = linkButton;
3693
4270
  exports.loadConfig = loadConfig;
@@ -3698,7 +4275,11 @@ exports.lookup = lookup;
3698
4275
  exports.lookupOptional = lookupOptional;
3699
4276
  exports.mentionableSelect = mentionableSelect;
3700
4277
  exports.messageCommand = messageCommand;
4278
+ exports.missingPermissions = missingPermissions;
3701
4279
  exports.modal = modal;
4280
+ exports.moderationCheck = moderationCheck;
4281
+ exports.namespaced = namespaced;
4282
+ exports.normalizeAutoDefer = normalizeAutoDefer;
3702
4283
  exports.normalizeCooldown = normalizeCooldown;
3703
4284
  exports.normalizeReply = normalizeReply;
3704
4285
  exports.option = option;
@@ -3722,6 +4303,7 @@ exports.row = row;
3722
4303
  exports.runGuards = runGuards;
3723
4304
  exports.safeFetch = safeFetch;
3724
4305
  exports.safeTry = safeTry;
4306
+ exports.showAndAwaitModal = showAndAwaitModal;
3725
4307
  exports.stringSelect = stringSelect;
3726
4308
  exports.subcommand = subcommand;
3727
4309
  exports.subcommandGroup = subcommandGroup;
@@ -3729,6 +4311,7 @@ exports.task = task;
3729
4311
  exports.textInput = textInput;
3730
4312
  exports.toAPIOption = toAPIOption;
3731
4313
  exports.toError = toError;
4314
+ exports.truncate = truncate;
3732
4315
  exports.userCommand = userCommand;
3733
4316
  exports.userSelect = userSelect;
3734
4317
  exports.webhookSink = webhookSink;