cordsmith 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -0
  3. package/package.json +72 -0
  4. package/src/handler/@types/command.ts +79 -0
  5. package/src/handler/@types/contextMenu.ts +80 -0
  6. package/src/handler/@types/event.ts +37 -0
  7. package/src/handler/@types/precondition.ts +28 -0
  8. package/src/handler/@types/task.ts +69 -0
  9. package/src/handler/command/CommandHandler.ts +192 -0
  10. package/src/handler/command/functions/UserError.ts +13 -0
  11. package/src/handler/command/functions/attachInteractionListener.ts +271 -0
  12. package/src/handler/command/functions/commandCache.ts +97 -0
  13. package/src/handler/command/functions/cooldowns.ts +93 -0
  14. package/src/handler/command/functions/customId.ts +78 -0
  15. package/src/handler/command/functions/loadCommands.ts +62 -0
  16. package/src/handler/command/functions/owners.ts +10 -0
  17. package/src/handler/command/functions/preconditions/BotPermissions.ts +34 -0
  18. package/src/handler/command/functions/preconditions/Cooldown.ts +37 -0
  19. package/src/handler/command/functions/preconditions/GuildOnly.ts +18 -0
  20. package/src/handler/command/functions/preconditions/OwnerOnly.ts +18 -0
  21. package/src/handler/command/functions/preconditions/UserPermissions.ts +33 -0
  22. package/src/handler/command/functions/registerCommands.ts +140 -0
  23. package/src/handler/command/functions/runPreconditions.ts +103 -0
  24. package/src/handler/command/index.ts +20 -0
  25. package/src/handler/context/ContextMenuHandler.ts +172 -0
  26. package/src/handler/context/functions/attachContextMenuListener.ts +174 -0
  27. package/src/handler/context/functions/loadContextMenus.ts +59 -0
  28. package/src/handler/context/functions/runContextMenuPreconditions.ts +114 -0
  29. package/src/handler/context/index.ts +12 -0
  30. package/src/handler/events/EventHandler.ts +45 -0
  31. package/src/handler/events/functions/attachEvents.ts +95 -0
  32. package/src/handler/events/functions/loadEvents.ts +93 -0
  33. package/src/handler/events/index.ts +2 -0
  34. package/src/handler/manager/HandlerManager.ts +225 -0
  35. package/src/handler/manager/index.ts +2 -0
  36. package/src/handler/manager/registrationPlans.ts +58 -0
  37. package/src/handler/tasks/TaskHandler.ts +73 -0
  38. package/src/handler/tasks/functions/loadTasks.ts +75 -0
  39. package/src/handler/tasks/functions/parseCron.ts +187 -0
  40. package/src/handler/tasks/functions/scheduleTask.ts +106 -0
  41. package/src/handler/tasks/index.ts +4 -0
  42. package/src/handler/utils/env.ts +7 -0
  43. package/src/handler/utils/files.ts +74 -0
  44. package/src/index.ts +39 -0
  45. package/src/structure/Client.ts +8 -0
  46. package/src/utils/index.ts +1 -0
  47. package/src/utils/logger.ts +63 -0
@@ -0,0 +1,271 @@
1
+ import type {
2
+ Collection,
3
+ InteractionReplyOptions,
4
+ MessageComponentInteraction,
5
+ ModalSubmitInteraction,
6
+ } from "discord.js";
7
+ import type { ClientClass } from "../../../structure/Client";
8
+ import { logger } from "../../../utils";
9
+ import type { SlashCommand } from "../../@types/command";
10
+ import type { Precondition } from "../../@types/precondition";
11
+ import type { CooldownStore } from "./cooldowns";
12
+ import { parseCustomId } from "./customId";
13
+ import { runPreconditions } from "./runPreconditions";
14
+ import { isUserError } from "./UserError";
15
+
16
+ export type AttachedInteractionListener = {
17
+ eventName: "interactionCreate";
18
+ fn: (interaction: any) => void;
19
+ };
20
+
21
+ // RepliableInteraction from discord.js is a union of *concrete* narrowed types,
22
+ // so the abstract base classes (MessageComponentInteraction etc.) aren't
23
+ // assignable to it. This structural type targets only what replySafe actually
24
+ // needs, which is both correct and sufficient.
25
+ type Repliable = {
26
+ deferred: boolean;
27
+ replied: boolean;
28
+ reply: (options: InteractionReplyOptions) => Promise<unknown>;
29
+ followUp: (options: InteractionReplyOptions) => Promise<unknown>;
30
+ };
31
+
32
+ async function replySafe(
33
+ interaction: Repliable,
34
+ payload: InteractionReplyOptions,
35
+ ): Promise<void> {
36
+ if (interaction.deferred || interaction.replied) {
37
+ await interaction.followUp(payload);
38
+ } else {
39
+ await interaction.reply(payload);
40
+ }
41
+ }
42
+
43
+ function enforceComponentOwnerOnly(options: {
44
+ cmd: SlashCommand;
45
+ payload: string | undefined;
46
+ userId: string;
47
+ }): { ok: true } | { ok: false; message: string } {
48
+ const { cmd, payload, userId } = options;
49
+
50
+ if (!cmd.meta?.componentOwnerOnly) return { ok: true };
51
+
52
+ if (!payload) {
53
+ return {
54
+ ok: false,
55
+ message:
56
+ "This interaction is missing an owner payload. Re-run the command.",
57
+ };
58
+ }
59
+
60
+ // Allow payload formats: "<userId>" or "<userId>:<anything>"
61
+ const ownerId = payload.split(":")[0];
62
+
63
+ if (ownerId !== userId) {
64
+ return { ok: false, message: "This interaction isn't for you." };
65
+ }
66
+
67
+ return { ok: true };
68
+ }
69
+
70
+ type ComponentCtx<T> = {
71
+ interaction: T;
72
+ client: ClientClass;
73
+ action: string;
74
+ payload?: string;
75
+ };
76
+
77
+ /**
78
+ * Shared routing logic for button, select menu, and modal interactions.
79
+ *
80
+ * Generic over T so each call site keeps its concrete interaction type,
81
+ * avoiding the contravariance errors that arise from a wide union handler type.
82
+ */
83
+ async function routeComponentInteraction<
84
+ T extends MessageComponentInteraction | ModalSubmitInteraction,
85
+ >(options: {
86
+ interaction: T;
87
+ commands: Collection<string, SlashCommand>;
88
+ client: ClientClass;
89
+ getHandler: (
90
+ cmd: SlashCommand,
91
+ action: string,
92
+ ) => ((ctx: ComponentCtx<T>) => Promise<void>) | undefined;
93
+ label: string;
94
+ }): Promise<void> {
95
+ const { interaction, commands, client, getHandler, label } = options;
96
+
97
+ const parsed = parseCustomId(interaction.customId);
98
+ if (!parsed.ok) return;
99
+
100
+ const cmd = commands.get(parsed.commandName);
101
+ if (!cmd) return;
102
+
103
+ const handler = getHandler(cmd, parsed.action);
104
+ if (!handler) return;
105
+
106
+ const ownerCheck = enforceComponentOwnerOnly({
107
+ cmd,
108
+ payload: parsed.payload,
109
+ userId: interaction.user.id,
110
+ });
111
+
112
+ if (!ownerCheck.ok) {
113
+ await replySafe(interaction, {
114
+ content: ownerCheck.message,
115
+ ephemeral: true,
116
+ });
117
+ return;
118
+ }
119
+
120
+ try {
121
+ await handler({
122
+ interaction,
123
+ client,
124
+ action: parsed.action,
125
+ payload: parsed.payload,
126
+ });
127
+ } catch (err) {
128
+ if (isUserError(err)) {
129
+ await replySafe(interaction, {
130
+ content: err.message,
131
+ ephemeral: err.ephemeral,
132
+ });
133
+ return;
134
+ }
135
+
136
+ logger.error(
137
+ `Error in ${label} handler for "${parsed.commandName}:${parsed.action}"`,
138
+ err,
139
+ );
140
+
141
+ await replySafe(interaction, {
142
+ content: "Something went wrong.",
143
+ ephemeral: true,
144
+ });
145
+ }
146
+ }
147
+
148
+ export function attachInteractionListener(options: {
149
+ client: ClientClass;
150
+ commands: Collection<string, SlashCommand>;
151
+ ownerIds: Set<string>;
152
+ cooldowns: CooldownStore;
153
+ preconditionRegistry: Map<string, Precondition>;
154
+ }): AttachedInteractionListener {
155
+ const { client, commands, ownerIds, cooldowns, preconditionRegistry } =
156
+ options;
157
+
158
+ const fn = async (interaction: any) => {
159
+ // ---- Autocomplete routing ----
160
+ if (interaction.isAutocomplete()) {
161
+ const command = commands.get(interaction.commandName);
162
+ if (!command?.autocomplete) return;
163
+
164
+ try {
165
+ await command.autocomplete({ interaction, client });
166
+ } catch (err) {
167
+ logger.error(
168
+ `Error in autocomplete for "${interaction.commandName}"`,
169
+ err,
170
+ );
171
+ }
172
+
173
+ return;
174
+ }
175
+
176
+ // ---- Button routing ----
177
+ if (interaction.isButton()) {
178
+ await routeComponentInteraction({
179
+ interaction,
180
+ commands,
181
+ client,
182
+ getHandler: (cmd, action) => cmd.buttons?.[action],
183
+ label: "button",
184
+ });
185
+ return;
186
+ }
187
+
188
+ // ---- Select menu routing ----
189
+ if (interaction.isAnySelectMenu()) {
190
+ await routeComponentInteraction({
191
+ interaction,
192
+ commands,
193
+ client,
194
+ getHandler: (cmd, action) => cmd.selectMenus?.[action],
195
+ label: "select menu",
196
+ });
197
+ return;
198
+ }
199
+
200
+ // ---- Modal routing ----
201
+ if (interaction.isModalSubmit()) {
202
+ await routeComponentInteraction({
203
+ interaction,
204
+ commands,
205
+ client,
206
+ getHandler: (cmd, action) => cmd.modals?.[action],
207
+ label: "modal",
208
+ });
209
+ return;
210
+ }
211
+
212
+ // ---- Slash command routing ----
213
+ if (!interaction.isChatInputCommand()) return;
214
+
215
+ const command = commands.get(interaction.commandName);
216
+
217
+ if (!command) {
218
+ // Don't expose internal state to users — log it instead.
219
+ logger.warn(
220
+ `Received unknown slash command: "${interaction.commandName}"`,
221
+ );
222
+ return;
223
+ }
224
+
225
+ // Run all preconditions (built-ins + any custom ones declared on the command)
226
+ const result = await runPreconditions({
227
+ ctx: {
228
+ interaction,
229
+ client,
230
+ meta: command.meta ?? {},
231
+ ownerIds,
232
+ cooldowns,
233
+ },
234
+ registry: preconditionRegistry,
235
+ });
236
+
237
+ if (!result.ok) {
238
+ await interaction.reply({
239
+ content: result.failure.message,
240
+ ephemeral: result.failure.ephemeral,
241
+ });
242
+ return;
243
+ }
244
+
245
+ try {
246
+ await command.execute({ interaction, client });
247
+ } catch (err) {
248
+ if (isUserError(err)) {
249
+ await replySafe(interaction, {
250
+ content: err.message,
251
+ ephemeral: err.ephemeral,
252
+ });
253
+ return;
254
+ }
255
+
256
+ logger.error(`Error executing "${interaction.commandName}"`, err);
257
+
258
+ await replySafe(interaction, {
259
+ content: "Something went wrong while running that command.",
260
+ ephemeral: true,
261
+ });
262
+ }
263
+ };
264
+
265
+ client.on("interactionCreate", fn);
266
+
267
+ return {
268
+ eventName: "interactionCreate",
269
+ fn,
270
+ };
271
+ }
@@ -0,0 +1,97 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export type RegisterScope =
5
+ | { mode: "guild"; guildId: string }
6
+ | { mode: "global" };
7
+
8
+ function cacheDir(): string {
9
+ return path.join(process.cwd(), ".cache", "discord-commands");
10
+ }
11
+
12
+ function hashText(text: string): string {
13
+ return new Bun.CryptoHasher("sha256").update(text).digest("hex");
14
+ }
15
+
16
+ export function safeCacheComponent(value: string): string {
17
+ const safe = value
18
+ .replace(/[^A-Za-z0-9_.-]/g, "_")
19
+ .replace(/\.{2,}/g, "_");
20
+
21
+ if (safe === value && safe.length > 0) return safe;
22
+
23
+ const fallback = safe.replace(/^_+|_+$/g, "") || "value";
24
+ return `${fallback}.${hashText(value).slice(0, 12)}`;
25
+ }
26
+
27
+ function fileBaseName(options: {
28
+ scope: RegisterScope;
29
+ applicationId: string;
30
+ envKey?: string;
31
+ }): string {
32
+ const { scope, applicationId, envKey } = options;
33
+
34
+ const applicationPart = safeCacheComponent(applicationId);
35
+ const envPart = envKey ? `.${safeCacheComponent(envKey)}` : "";
36
+
37
+ if (scope.mode === "global") {
38
+ return `global.${applicationPart}${envPart}.hash`;
39
+ }
40
+
41
+ return `guild.${safeCacheComponent(
42
+ scope.guildId,
43
+ )}.${applicationPart}${envPart}.hash`;
44
+ }
45
+
46
+ function hashFilePath(options: {
47
+ scope: RegisterScope;
48
+ applicationId: string;
49
+ envKey?: string;
50
+ }): string {
51
+ return path.join(cacheDir(), fileBaseName(options));
52
+ }
53
+
54
+ async function ensureDirExists(dir: string): Promise<void> {
55
+ await mkdir(dir, { recursive: true });
56
+ }
57
+
58
+ export async function readCachedHash(options: {
59
+ scope: RegisterScope;
60
+ applicationId: string;
61
+ envKey?: string;
62
+ }): Promise<string | null> {
63
+ const file = hashFilePath(options);
64
+
65
+ try {
66
+ const text = await Bun.file(file).text();
67
+ const trimmed = text.trim();
68
+ return trimmed.length > 0 ? trimmed : null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ export async function writeCachedHash(options: {
75
+ scope: RegisterScope;
76
+ applicationId: string;
77
+ hash: string;
78
+ envKey?: string;
79
+ }): Promise<void> {
80
+ await ensureDirExists(cacheDir());
81
+
82
+ const file = hashFilePath({
83
+ scope: options.scope,
84
+ applicationId: options.applicationId,
85
+ envKey: options.envKey,
86
+ });
87
+
88
+ await Bun.write(file, `${options.hash}\n`);
89
+ }
90
+
91
+ /**
92
+ * Hash the final command JSON payload we send to Discord.
93
+ */
94
+ export function hashCommandJson(commandJson: unknown[]): string {
95
+ const text = JSON.stringify(commandJson);
96
+ return hashText(text);
97
+ }
@@ -0,0 +1,93 @@
1
+ export type CooldownScope = "user" | "guild" | "global";
2
+
3
+ type Key = string;
4
+
5
+ export type CooldownStoreOptions = {
6
+ maxEntries?: number;
7
+ pruneEvery?: number;
8
+ };
9
+
10
+ export class CooldownStore {
11
+ private readonly until = new Map<Key, number>();
12
+ private readonly maxEntries: number;
13
+ private readonly pruneEvery: number;
14
+ private operations = 0;
15
+
16
+ constructor(options: CooldownStoreOptions = {}) {
17
+ this.maxEntries = Math.max(1, options.maxEntries ?? 10_000);
18
+ this.pruneEvery = Math.max(1, options.pruneEvery ?? 100);
19
+ }
20
+
21
+ public get size(): number {
22
+ return this.until.size;
23
+ }
24
+
25
+ private buildKey(
26
+ commandName: string,
27
+ scope: CooldownScope,
28
+ userId: string,
29
+ guildId: string | null,
30
+ ): Key {
31
+ switch (scope) {
32
+ case "user":
33
+ return `${commandName}:user:${userId}`;
34
+ case "guild":
35
+ // Fall back to userId if somehow called outside a guild
36
+ return `${commandName}:guild:${guildId ?? userId}`;
37
+ case "global":
38
+ return `${commandName}:global`;
39
+ }
40
+ }
41
+
42
+ public getRemainingMs(
43
+ commandName: string,
44
+ scope: CooldownScope,
45
+ userId: string,
46
+ guildId: string | null,
47
+ ): number {
48
+ const key = this.buildKey(commandName, scope, userId, guildId);
49
+ const now = Date.now();
50
+ const end = this.until.get(key) ?? 0;
51
+ const remaining = Math.max(0, end - now);
52
+
53
+ // Prune expired entry on read to prevent unbounded memory growth
54
+ if (remaining === 0) this.until.delete(key);
55
+ this.pruneIfNeeded(now);
56
+
57
+ return remaining;
58
+ }
59
+
60
+ public set(
61
+ commandName: string,
62
+ scope: CooldownScope,
63
+ userId: string,
64
+ guildId: string | null,
65
+ cooldownMs: number,
66
+ ): void {
67
+ const now = Date.now();
68
+ const key = this.buildKey(commandName, scope, userId, guildId);
69
+ this.until.set(key, now + cooldownMs);
70
+ this.pruneIfNeeded(now);
71
+ }
72
+
73
+ private pruneIfNeeded(now: number): void {
74
+ this.operations += 1;
75
+
76
+ if (
77
+ this.until.size <= this.maxEntries &&
78
+ this.operations % this.pruneEvery !== 0
79
+ ) {
80
+ return;
81
+ }
82
+
83
+ for (const [key, expiresAt] of this.until) {
84
+ if (expiresAt <= now) this.until.delete(key);
85
+ }
86
+
87
+ while (this.until.size > this.maxEntries) {
88
+ const oldest = this.until.keys().next().value;
89
+ if (oldest === undefined) break;
90
+ this.until.delete(oldest);
91
+ }
92
+ }
93
+ }
@@ -0,0 +1,78 @@
1
+ export type ParsedCustomId =
2
+ | {
3
+ ok: true;
4
+ commandName: string;
5
+ action: string;
6
+ payload?: string;
7
+ }
8
+ | {
9
+ ok: false;
10
+ reason: string;
11
+ };
12
+
13
+ export const DISCORD_CUSTOM_ID_MAX_LENGTH = 100;
14
+
15
+ function validateSegment(label: string, value: string): void {
16
+ if (value.trim().length === 0) {
17
+ throw new Error(`${label} cannot be empty.`);
18
+ }
19
+
20
+ if (value.includes(":")) {
21
+ throw new Error(`${label} cannot contain ":".`);
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Format: cmd:<commandName>:<action>[:<payload>]
27
+ */
28
+ export function parseCustomId(customId: string): ParsedCustomId {
29
+ if (!customId.startsWith("cmd:")) {
30
+ return { ok: false, reason: "Not a cmd:* customId" };
31
+ }
32
+
33
+ // cmd:<command>:<action>[:<payload>]
34
+ const parts = customId.split(":");
35
+
36
+ if (parts.length < 3) {
37
+ return { ok: false, reason: "Invalid cmd customId format" };
38
+ }
39
+
40
+ const commandName = parts[1]?.trim();
41
+ const action = parts[2]?.trim();
42
+ const payload = parts.slice(3).join(":").trim();
43
+
44
+ if (!commandName) return { ok: false, reason: "Missing commandName" };
45
+ if (!action) return { ok: false, reason: "Missing action" };
46
+
47
+ return {
48
+ ok: true,
49
+ commandName,
50
+ action,
51
+ payload: payload.length > 0 ? payload : undefined,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Helper to build IDs consistently.
57
+ */
58
+ export function makeCustomId(
59
+ commandName: string,
60
+ action: string,
61
+ payload?: string,
62
+ ): string {
63
+ validateSegment("commandName", commandName);
64
+ validateSegment("action", action);
65
+
66
+ const id =
67
+ payload === undefined || payload.length === 0
68
+ ? `cmd:${commandName}:${action}`
69
+ : `cmd:${commandName}:${action}:${payload}`;
70
+
71
+ if (id.length > DISCORD_CUSTOM_ID_MAX_LENGTH) {
72
+ throw new Error(
73
+ `customId is ${id.length} characters; Discord allows at most ${DISCORD_CUSTOM_ID_MAX_LENGTH}.`,
74
+ );
75
+ }
76
+
77
+ return id;
78
+ }
@@ -0,0 +1,62 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { Collection } from "discord.js";
3
+ import { logger } from "../../../utils";
4
+ import type { SlashCommand } from "../../@types/command";
5
+ import { discoverModuleFiles } from "../../utils/files";
6
+
7
+ type LoadResult = {
8
+ commands: Collection<string, SlashCommand>;
9
+ };
10
+
11
+ function isSlashCommandModule(mod: unknown): mod is SlashCommand {
12
+ if (!mod || typeof mod !== "object") return false;
13
+
14
+ const m = mod as Partial<SlashCommand>;
15
+ return (
16
+ typeof m.execute === "function" &&
17
+ typeof m.data === "object" &&
18
+ typeof m.data?.toJSON === "function" &&
19
+ // Ensure data.name is present and non-empty so we never register a
20
+ // command under "undefined" or produce a confusing duplicate-name error
21
+ typeof (m.data as { name?: unknown }).name === "string" &&
22
+ ((m.data as { name?: unknown }).name as string).length > 0
23
+ );
24
+ }
25
+
26
+ export async function loadCommandsFromDisk(options: {
27
+ commandsDir: string;
28
+ extensions: string[];
29
+ }): Promise<LoadResult> {
30
+ const { rootDir, files } = await discoverModuleFiles({
31
+ dir: options.commandsDir,
32
+ extensions: options.extensions,
33
+ });
34
+
35
+ const commands = new Collection<string, SlashCommand>();
36
+ let loaded = 0;
37
+
38
+ for (const filePath of files) {
39
+ const fileUrl = pathToFileURL(filePath).href;
40
+ const imported = await import(fileUrl);
41
+ const cmd = imported.default;
42
+
43
+ if (!isSlashCommandModule(cmd)) {
44
+ logger.warn(`Skipping invalid command module: ${filePath}`);
45
+ continue;
46
+ }
47
+
48
+ const name = cmd.data.name;
49
+
50
+ if (commands.has(name)) {
51
+ throw new Error(
52
+ `Duplicate command name "${name}" detected: ${filePath}`,
53
+ );
54
+ }
55
+
56
+ commands.set(name, cmd);
57
+ loaded += 1;
58
+ }
59
+
60
+ logger.info(`Loaded ${loaded} slash command(s) from ${rootDir}`);
61
+ return { commands };
62
+ }
@@ -0,0 +1,10 @@
1
+ export function parseOwnerIds(raw: string | undefined): Set<string> {
2
+ if (!raw) return new Set();
3
+
4
+ const ids = raw
5
+ .split(",")
6
+ .map((s) => s.trim())
7
+ .filter(Boolean);
8
+
9
+ return new Set(ids);
10
+ }
@@ -0,0 +1,34 @@
1
+ import type { PermissionsString } from "discord.js";
2
+ import type { Precondition } from "../../../@types/precondition";
3
+
4
+ function missingPerms(
5
+ has: ReadonlySet<string>,
6
+ required: PermissionsString[],
7
+ ): PermissionsString[] {
8
+ return required.filter((p) => !has.has(p));
9
+ }
10
+
11
+ export const BotPermissions: Precondition = {
12
+ name: "BotPermissions",
13
+ run({ meta, interaction }) {
14
+ if (!meta.botPermissions?.length) return { ok: true };
15
+ if (!interaction.inGuild()) return { ok: true };
16
+
17
+ const me = interaction.guild?.members.me;
18
+ const botPerms = me?.permissionsIn(interaction.channelId);
19
+ const missing = missingPerms(
20
+ new Set(botPerms?.toArray() ?? []),
21
+ meta.botPermissions,
22
+ );
23
+
24
+ if (missing.length > 0) {
25
+ return {
26
+ ok: false,
27
+ message: `I'm missing permissions: ${missing.join(", ")}`,
28
+ ephemeral: true,
29
+ };
30
+ }
31
+
32
+ return { ok: true };
33
+ },
34
+ };
@@ -0,0 +1,37 @@
1
+ import type { Precondition } from "../../../@types/precondition";
2
+
3
+ export const Cooldown: Precondition = {
4
+ name: "Cooldown",
5
+ run({ meta, interaction, cooldowns }) {
6
+ if (!meta.cooldownMs || meta.cooldownMs <= 0) return { ok: true };
7
+
8
+ const scope = meta.cooldownScope ?? "user";
9
+ const guildId = interaction.guildId;
10
+
11
+ const remaining = cooldowns.getRemainingMs(
12
+ interaction.commandName,
13
+ scope,
14
+ interaction.user.id,
15
+ guildId,
16
+ );
17
+
18
+ if (remaining > 0) {
19
+ return {
20
+ ok: false,
21
+ message: `Please wait ${Math.ceil(remaining / 1000)}s before using this command again.`,
22
+ ephemeral: true,
23
+ };
24
+ }
25
+
26
+ // Set the cooldown now — after all other checks pass but before execute()
27
+ cooldowns.set(
28
+ interaction.commandName,
29
+ scope,
30
+ interaction.user.id,
31
+ guildId,
32
+ meta.cooldownMs,
33
+ );
34
+
35
+ return { ok: true };
36
+ },
37
+ };
@@ -0,0 +1,18 @@
1
+ import type { Precondition } from "../../../@types/precondition";
2
+
3
+ export const GuildOnly: Precondition = {
4
+ name: "GuildOnly",
5
+ run({ meta, interaction }) {
6
+ if (!meta.guildOnly) return { ok: true };
7
+
8
+ if (!interaction.inGuild()) {
9
+ return {
10
+ ok: false,
11
+ message: "This command can only be used in a server.",
12
+ ephemeral: true,
13
+ };
14
+ }
15
+
16
+ return { ok: true };
17
+ },
18
+ };