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