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.
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/package.json +72 -0
- package/src/handler/@types/command.ts +79 -0
- package/src/handler/@types/contextMenu.ts +80 -0
- package/src/handler/@types/event.ts +37 -0
- package/src/handler/@types/precondition.ts +28 -0
- package/src/handler/@types/task.ts +69 -0
- package/src/handler/command/CommandHandler.ts +192 -0
- package/src/handler/command/functions/UserError.ts +13 -0
- package/src/handler/command/functions/attachInteractionListener.ts +271 -0
- package/src/handler/command/functions/commandCache.ts +97 -0
- package/src/handler/command/functions/cooldowns.ts +93 -0
- package/src/handler/command/functions/customId.ts +78 -0
- package/src/handler/command/functions/loadCommands.ts +62 -0
- package/src/handler/command/functions/owners.ts +10 -0
- package/src/handler/command/functions/preconditions/BotPermissions.ts +34 -0
- package/src/handler/command/functions/preconditions/Cooldown.ts +37 -0
- package/src/handler/command/functions/preconditions/GuildOnly.ts +18 -0
- package/src/handler/command/functions/preconditions/OwnerOnly.ts +18 -0
- package/src/handler/command/functions/preconditions/UserPermissions.ts +33 -0
- package/src/handler/command/functions/registerCommands.ts +140 -0
- package/src/handler/command/functions/runPreconditions.ts +103 -0
- package/src/handler/command/index.ts +20 -0
- package/src/handler/context/ContextMenuHandler.ts +172 -0
- package/src/handler/context/functions/attachContextMenuListener.ts +174 -0
- package/src/handler/context/functions/loadContextMenus.ts +59 -0
- package/src/handler/context/functions/runContextMenuPreconditions.ts +114 -0
- package/src/handler/context/index.ts +12 -0
- package/src/handler/events/EventHandler.ts +45 -0
- package/src/handler/events/functions/attachEvents.ts +95 -0
- package/src/handler/events/functions/loadEvents.ts +93 -0
- package/src/handler/events/index.ts +2 -0
- package/src/handler/manager/HandlerManager.ts +225 -0
- package/src/handler/manager/index.ts +2 -0
- package/src/handler/manager/registrationPlans.ts +58 -0
- package/src/handler/tasks/TaskHandler.ts +73 -0
- package/src/handler/tasks/functions/loadTasks.ts +75 -0
- package/src/handler/tasks/functions/parseCron.ts +187 -0
- package/src/handler/tasks/functions/scheduleTask.ts +106 -0
- package/src/handler/tasks/index.ts +4 -0
- package/src/handler/utils/env.ts +7 -0
- package/src/handler/utils/files.ts +74 -0
- package/src/index.ts +39 -0
- package/src/structure/Client.ts +8 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/logger.ts +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TDanks2000
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Cordsmith
|
|
2
|
+
|
|
3
|
+
Cordsmith is a Bun-first Discord.js handler library extracted from the T+C bot.
|
|
4
|
+
It provides handlers for slash commands, context menus, events, scheduled tasks,
|
|
5
|
+
shared preconditions, command registration caching, and component custom IDs.
|
|
6
|
+
|
|
7
|
+
## Install locally with Bun
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bun add cordsmith@file:../cordsmith
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Basic usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { HandlerManager } from "cordsmith";
|
|
17
|
+
|
|
18
|
+
const handlers = new HandlerManager({
|
|
19
|
+
client,
|
|
20
|
+
commands: {
|
|
21
|
+
commandsDir: "src/commands",
|
|
22
|
+
extensions: [".ts", ".js"],
|
|
23
|
+
register: {
|
|
24
|
+
token: Bun.env.DISCORD_TOKEN!,
|
|
25
|
+
applicationId: Bun.env.DISCORD_APPLICATION_ID!,
|
|
26
|
+
where: { mode: "global" },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
events: {
|
|
30
|
+
eventsDir: "src/events",
|
|
31
|
+
extensions: [".ts", ".js"],
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
await handlers.init();
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Production Notes
|
|
39
|
+
|
|
40
|
+
- Keep module directories scoped to trusted command, event, context menu, and task code. Cordsmith validates extensions and skips files that resolve outside the configured root.
|
|
41
|
+
- Use `makeCustomId()` for component IDs. It enforces Discord's 100 character limit and rejects segments that would break routing.
|
|
42
|
+
- `HandlerManager` registers slash commands and context menus in one payload per Discord scope, so enabling both does not overwrite either set.
|
|
43
|
+
- `HandlerManager.shutdown()` detaches event, command, and context menu listeners and cancels scheduled tasks for clean restarts.
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cordsmith",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A Bun-first Discord.js handler for slash commands, context menus, events, and scheduled tasks.",
|
|
5
|
+
"author": "TDanks2000",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/TDanks2000/cordsmith.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/TDanks2000/cordsmith/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/TDanks2000/cordsmith#readme",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"discord",
|
|
17
|
+
"discord.js",
|
|
18
|
+
"bun",
|
|
19
|
+
"handler",
|
|
20
|
+
"slash-commands",
|
|
21
|
+
"context-menu",
|
|
22
|
+
"events"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"module": "./src/index.ts",
|
|
26
|
+
"types": "./src/index.ts",
|
|
27
|
+
"packageManager": "bun@1.3.13",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./src/index.ts",
|
|
31
|
+
"import": "./src/index.ts"
|
|
32
|
+
},
|
|
33
|
+
"./command": {
|
|
34
|
+
"types": "./src/handler/command/index.ts",
|
|
35
|
+
"import": "./src/handler/command/index.ts"
|
|
36
|
+
},
|
|
37
|
+
"./context": {
|
|
38
|
+
"types": "./src/handler/context/index.ts",
|
|
39
|
+
"import": "./src/handler/context/index.ts"
|
|
40
|
+
},
|
|
41
|
+
"./events": {
|
|
42
|
+
"types": "./src/handler/events/index.ts",
|
|
43
|
+
"import": "./src/handler/events/index.ts"
|
|
44
|
+
},
|
|
45
|
+
"./manager": {
|
|
46
|
+
"types": "./src/handler/manager/index.ts",
|
|
47
|
+
"import": "./src/handler/manager/index.ts"
|
|
48
|
+
},
|
|
49
|
+
"./tasks": {
|
|
50
|
+
"types": "./src/handler/tasks/index.ts",
|
|
51
|
+
"import": "./src/handler/tasks/index.ts"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"files": [
|
|
55
|
+
"src",
|
|
56
|
+
"README.md",
|
|
57
|
+
"LICENSE"
|
|
58
|
+
],
|
|
59
|
+
"scripts": {
|
|
60
|
+
"check": "tsc --noEmit",
|
|
61
|
+
"test": "bun test"
|
|
62
|
+
},
|
|
63
|
+
"peerDependencies": {
|
|
64
|
+
"discord.js": "^14.26.3"
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"@types/bun": "^1.3.13",
|
|
68
|
+
"@types/node": "^25.6.0",
|
|
69
|
+
"discord.js": "^14.26.3",
|
|
70
|
+
"typescript": "^6.0.3"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnySelectMenuInteraction,
|
|
3
|
+
AutocompleteInteraction,
|
|
4
|
+
ButtonInteraction,
|
|
5
|
+
ChatInputCommandInteraction,
|
|
6
|
+
ModalSubmitInteraction,
|
|
7
|
+
PermissionsString,
|
|
8
|
+
} from "discord.js";
|
|
9
|
+
import type { ClientClass } from "../../structure/Client";
|
|
10
|
+
|
|
11
|
+
export type CommandMeta = {
|
|
12
|
+
guildOnly?: boolean;
|
|
13
|
+
ownerOnly?: boolean;
|
|
14
|
+
|
|
15
|
+
cooldownMs?: number;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether the cooldown is scoped per-user (default), per-guild, or global.
|
|
19
|
+
* - "user" — each user gets their own cooldown bucket (default)
|
|
20
|
+
* - "guild" — the whole guild shares one bucket
|
|
21
|
+
* - "global" — every invocation shares one bucket regardless of who or where
|
|
22
|
+
*/
|
|
23
|
+
cooldownScope?: "user" | "guild" | "global";
|
|
24
|
+
|
|
25
|
+
userPermissions?: PermissionsString[];
|
|
26
|
+
botPermissions?: PermissionsString[];
|
|
27
|
+
|
|
28
|
+
defaultMemberPermissions?: PermissionsString[];
|
|
29
|
+
|
|
30
|
+
componentOwnerOnly?: boolean;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Names of additional preconditions to run before execute().
|
|
34
|
+
* Built-in preconditions (OwnerOnly, GuildOnly, Cooldown, UserPermissions,
|
|
35
|
+
* BotPermissions) are applied automatically from the meta fields above —
|
|
36
|
+
* you only need this for custom preconditions registered on CommandHandler.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* meta: { preconditions: ["HasRole:moderator", "PremiumOnly"] }
|
|
40
|
+
*/
|
|
41
|
+
preconditions?: string[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type SlashCommandData = {
|
|
45
|
+
name: string;
|
|
46
|
+
toJSON: () => unknown;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type CommandContext<TInteraction, TClient = ClientClass> = {
|
|
50
|
+
interaction: TInteraction;
|
|
51
|
+
client: TClient;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type ComponentHandler<TInteraction, TClient = ClientClass> = (ctx: {
|
|
55
|
+
interaction: TInteraction;
|
|
56
|
+
client: TClient;
|
|
57
|
+
action: string;
|
|
58
|
+
payload?: string;
|
|
59
|
+
}) => Promise<void>;
|
|
60
|
+
|
|
61
|
+
export type SlashCommand<TClient = ClientClass> = {
|
|
62
|
+
data: SlashCommandData;
|
|
63
|
+
meta?: CommandMeta;
|
|
64
|
+
|
|
65
|
+
execute: (
|
|
66
|
+
ctx: CommandContext<ChatInputCommandInteraction, TClient>,
|
|
67
|
+
) => Promise<void>;
|
|
68
|
+
|
|
69
|
+
autocomplete?: (
|
|
70
|
+
ctx: CommandContext<AutocompleteInteraction, TClient>,
|
|
71
|
+
) => Promise<void>;
|
|
72
|
+
|
|
73
|
+
buttons?: Record<string, ComponentHandler<ButtonInteraction, TClient>>;
|
|
74
|
+
selectMenus?: Record<
|
|
75
|
+
string,
|
|
76
|
+
ComponentHandler<AnySelectMenuInteraction, TClient>
|
|
77
|
+
>;
|
|
78
|
+
modals?: Record<string, ComponentHandler<ModalSubmitInteraction, TClient>>;
|
|
79
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MessageContextMenuCommandInteraction,
|
|
3
|
+
PermissionsString,
|
|
4
|
+
UserContextMenuCommandInteraction,
|
|
5
|
+
} from "discord.js";
|
|
6
|
+
import type { ClientClass } from "../../structure/Client";
|
|
7
|
+
|
|
8
|
+
export type ContextMenuMeta = {
|
|
9
|
+
guildOnly?: boolean;
|
|
10
|
+
ownerOnly?: boolean;
|
|
11
|
+
cooldownMs?: number;
|
|
12
|
+
cooldownScope?: "user" | "guild" | "global";
|
|
13
|
+
userPermissions?: PermissionsString[];
|
|
14
|
+
botPermissions?: PermissionsString[];
|
|
15
|
+
defaultMemberPermissions?: PermissionsString[];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Names of custom preconditions to run before execute().
|
|
19
|
+
*/
|
|
20
|
+
preconditions?: string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ContextMenuCommandData = {
|
|
24
|
+
name: string;
|
|
25
|
+
toJSON: () => unknown;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type UserContextMenuCommand<TClient = ClientClass> = {
|
|
29
|
+
type: "user";
|
|
30
|
+
data: ContextMenuCommandData;
|
|
31
|
+
meta?: ContextMenuMeta;
|
|
32
|
+
execute: (ctx: {
|
|
33
|
+
interaction: UserContextMenuCommandInteraction;
|
|
34
|
+
client: TClient;
|
|
35
|
+
}) => Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type MessageContextMenuCommand<TClient = ClientClass> = {
|
|
39
|
+
type: "message";
|
|
40
|
+
data: ContextMenuCommandData;
|
|
41
|
+
meta?: ContextMenuMeta;
|
|
42
|
+
execute: (ctx: {
|
|
43
|
+
interaction: MessageContextMenuCommandInteraction;
|
|
44
|
+
client: TClient;
|
|
45
|
+
}) => Promise<void>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type ContextMenuCommand<TClient = ClientClass> =
|
|
49
|
+
| UserContextMenuCommand<TClient>
|
|
50
|
+
| MessageContextMenuCommand<TClient>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Helper for type-safe user context menu command definitions.
|
|
54
|
+
*
|
|
55
|
+
* Usage:
|
|
56
|
+
* export default defineUserContextMenu({
|
|
57
|
+
* data: new ContextMenuCommandBuilder().setName("Get Avatar"),
|
|
58
|
+
* async execute({ interaction, client }) {}
|
|
59
|
+
* })
|
|
60
|
+
*/
|
|
61
|
+
export function defineUserContextMenu(
|
|
62
|
+
cmd: Omit<UserContextMenuCommand, "type">,
|
|
63
|
+
): UserContextMenuCommand {
|
|
64
|
+
return { ...cmd, type: "user" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Helper for type-safe message context menu command definitions.
|
|
69
|
+
*
|
|
70
|
+
* Usage:
|
|
71
|
+
* export default defineMessageContextMenu({
|
|
72
|
+
* data: new ContextMenuCommandBuilder().setName("Translate"),
|
|
73
|
+
* async execute({ interaction, client }) {}
|
|
74
|
+
* })
|
|
75
|
+
*/
|
|
76
|
+
export function defineMessageContextMenu(
|
|
77
|
+
cmd: Omit<MessageContextMenuCommand, "type">,
|
|
78
|
+
): MessageContextMenuCommand {
|
|
79
|
+
return { ...cmd, type: "message" };
|
|
80
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ClientEvents } from "discord.js";
|
|
2
|
+
import type { ClientClass } from "../../structure/Client";
|
|
3
|
+
|
|
4
|
+
export type EventName = keyof ClientEvents;
|
|
5
|
+
|
|
6
|
+
export type EventListenerModule<
|
|
7
|
+
K extends EventName = EventName,
|
|
8
|
+
TClient = ClientClass,
|
|
9
|
+
> = {
|
|
10
|
+
once?: boolean;
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
order?: number;
|
|
13
|
+
|
|
14
|
+
execute: (ctx: {
|
|
15
|
+
client: TClient;
|
|
16
|
+
args: ClientEvents[K];
|
|
17
|
+
}) => Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Helper for better type inference in event files.
|
|
22
|
+
*
|
|
23
|
+
* Usage:
|
|
24
|
+
* export default defineEvent({
|
|
25
|
+
* name: "ready",
|
|
26
|
+
* once: true,
|
|
27
|
+
* async execute({ client }) {}
|
|
28
|
+
* })
|
|
29
|
+
*
|
|
30
|
+
* Note: "name" is inferred from the folder, not required here; this helper
|
|
31
|
+
* simply improves typing for args and ctx.
|
|
32
|
+
*/
|
|
33
|
+
export function defineEvent<K extends EventName>(
|
|
34
|
+
mod: EventListenerModule<K>,
|
|
35
|
+
): EventListenerModule<K> {
|
|
36
|
+
return mod;
|
|
37
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ChatInputCommandInteraction } from "discord.js";
|
|
2
|
+
import type { ClientClass } from "../../structure/Client";
|
|
3
|
+
import type { CooldownStore } from "../command/functions/cooldowns";
|
|
4
|
+
import type { CommandMeta } from "./command";
|
|
5
|
+
|
|
6
|
+
export type PreconditionResult =
|
|
7
|
+
| { ok: true }
|
|
8
|
+
| { ok: false; message: string; ephemeral?: boolean };
|
|
9
|
+
|
|
10
|
+
export type PreconditionContext = {
|
|
11
|
+
interaction: ChatInputCommandInteraction;
|
|
12
|
+
client: ClientClass;
|
|
13
|
+
meta: CommandMeta;
|
|
14
|
+
ownerIds: Set<string>;
|
|
15
|
+
cooldowns: CooldownStore;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type Precondition = {
|
|
19
|
+
/**
|
|
20
|
+
* Unique name used to reference this precondition in command meta.
|
|
21
|
+
* e.g. "GuildOnly", "HasRole:moderator"
|
|
22
|
+
*/
|
|
23
|
+
readonly name: string;
|
|
24
|
+
|
|
25
|
+
run: (
|
|
26
|
+
ctx: PreconditionContext,
|
|
27
|
+
) => Promise<PreconditionResult> | PreconditionResult;
|
|
28
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ClientClass } from "../../structure/Client";
|
|
2
|
+
|
|
3
|
+
export type TaskContext<TClient = ClientClass> = {
|
|
4
|
+
client: TClient;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type TaskModule<TClient = ClientClass> = {
|
|
8
|
+
/**
|
|
9
|
+
* Human-readable name shown in logs.
|
|
10
|
+
* Inferred from the filename if not provided.
|
|
11
|
+
*/
|
|
12
|
+
name?: string;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* How often the task runs, in milliseconds.
|
|
16
|
+
*
|
|
17
|
+
* Alternatively, provide a cron expression via `cron`.
|
|
18
|
+
* Exactly one of `intervalMs` or `cron` must be set.
|
|
19
|
+
*/
|
|
20
|
+
intervalMs?: number;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Cron expression (standard 5-field: "min hour dom mon dow").
|
|
24
|
+
*
|
|
25
|
+
* Examples:
|
|
26
|
+
* "0 * * * *" — every hour on the hour
|
|
27
|
+
* "30 9 * * 1" — every Monday at 09:30
|
|
28
|
+
* "* /5 * * * *" — every 5 minutes
|
|
29
|
+
*
|
|
30
|
+
* Requires `intervalMs` to be unset.
|
|
31
|
+
*/
|
|
32
|
+
cron?: string;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether to run the task immediately on startup before the first
|
|
36
|
+
* interval/cron tick. Defaults to false.
|
|
37
|
+
*/
|
|
38
|
+
runOnStart?: boolean;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* If false, the task is loaded but never scheduled. Defaults to true.
|
|
42
|
+
*/
|
|
43
|
+
enabled?: boolean;
|
|
44
|
+
|
|
45
|
+
execute: (ctx: TaskContext<TClient>) => Promise<void>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type LoadedTask<TClient = ClientClass> = {
|
|
49
|
+
name: string;
|
|
50
|
+
filePath: string;
|
|
51
|
+
intervalMs?: number;
|
|
52
|
+
cron?: string;
|
|
53
|
+
runOnStart: boolean;
|
|
54
|
+
execute: TaskModule<TClient>["execute"];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Helper for better type inference in task files.
|
|
59
|
+
*
|
|
60
|
+
* Usage:
|
|
61
|
+
* export default defineTask({
|
|
62
|
+
* intervalMs: 60_000,
|
|
63
|
+
* runOnStart: true,
|
|
64
|
+
* async execute({ client }) {}
|
|
65
|
+
* })
|
|
66
|
+
*/
|
|
67
|
+
export function defineTask(mod: TaskModule): TaskModule {
|
|
68
|
+
return mod;
|
|
69
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { Collection, PermissionsBitField } from "discord.js";
|
|
2
|
+
import type { ClientClass } from "../../structure/Client";
|
|
3
|
+
import type { SlashCommand } from "../@types/command";
|
|
4
|
+
import type { Precondition } from "../@types/precondition";
|
|
5
|
+
import {
|
|
6
|
+
type AttachedInteractionListener,
|
|
7
|
+
attachInteractionListener,
|
|
8
|
+
} from "./functions/attachInteractionListener";
|
|
9
|
+
import { CooldownStore } from "./functions/cooldowns";
|
|
10
|
+
import { loadCommandsFromDisk } from "./functions/loadCommands";
|
|
11
|
+
import { parseOwnerIds } from "./functions/owners";
|
|
12
|
+
import {
|
|
13
|
+
type RegisterMode,
|
|
14
|
+
registerCommands,
|
|
15
|
+
} from "./functions/registerCommands";
|
|
16
|
+
|
|
17
|
+
export type CommandHandlerOptions = {
|
|
18
|
+
client: ClientClass;
|
|
19
|
+
|
|
20
|
+
commandsDir: string;
|
|
21
|
+
|
|
22
|
+
register?: {
|
|
23
|
+
token: string;
|
|
24
|
+
applicationId: string;
|
|
25
|
+
where: RegisterMode;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
extensions?: string[];
|
|
29
|
+
|
|
30
|
+
ownerIds?: string[];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* If true (default), skip REST registration when commands haven't changed.
|
|
34
|
+
*/
|
|
35
|
+
registrationCache?: boolean;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Optionally provide an external precondition registry (e.g. from
|
|
39
|
+
* HandlerManager) so preconditions are shared across multiple handlers.
|
|
40
|
+
* If omitted, a fresh registry is created for this handler only.
|
|
41
|
+
*/
|
|
42
|
+
preconditionRegistry?: Map<string, Precondition>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export class CommandHandler {
|
|
46
|
+
public readonly commands = new Collection<string, SlashCommand>();
|
|
47
|
+
|
|
48
|
+
private readonly client: ClientClass;
|
|
49
|
+
private readonly commandsDir: string;
|
|
50
|
+
private readonly registerConfig?: CommandHandlerOptions["register"];
|
|
51
|
+
private readonly extensions: string[];
|
|
52
|
+
private readonly ownerIds: Set<string>;
|
|
53
|
+
private readonly cooldowns = new CooldownStore();
|
|
54
|
+
private readonly registrationCache: boolean;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Registry of custom preconditions, keyed by name.
|
|
58
|
+
* Built-ins (OwnerOnly, GuildOnly, Cooldown, UserPermissions, BotPermissions)
|
|
59
|
+
* are applied automatically and do not need to be registered here.
|
|
60
|
+
*/
|
|
61
|
+
private readonly preconditionRegistry: Map<string, Precondition>;
|
|
62
|
+
private attachedListener?: AttachedInteractionListener;
|
|
63
|
+
|
|
64
|
+
private initialized = false;
|
|
65
|
+
|
|
66
|
+
constructor(options: CommandHandlerOptions) {
|
|
67
|
+
this.client = options.client;
|
|
68
|
+
this.commandsDir = options.commandsDir;
|
|
69
|
+
this.registerConfig = options.register;
|
|
70
|
+
this.extensions = options.extensions ?? [".ts"];
|
|
71
|
+
|
|
72
|
+
this.ownerIds = options.ownerIds
|
|
73
|
+
? new Set(options.ownerIds)
|
|
74
|
+
: parseOwnerIds(Bun.env.DISCORD_OWNER_IDS);
|
|
75
|
+
|
|
76
|
+
this.registrationCache = options.registrationCache ?? true;
|
|
77
|
+
|
|
78
|
+
// Use a shared registry if provided (e.g. from HandlerManager),
|
|
79
|
+
// otherwise create a fresh one scoped to this handler.
|
|
80
|
+
this.preconditionRegistry =
|
|
81
|
+
options.preconditionRegistry ?? new Map<string, Precondition>();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Register a custom precondition so commands can reference it by name
|
|
86
|
+
* in their `meta.preconditions` array.
|
|
87
|
+
*
|
|
88
|
+
* Must be called before `init()`.
|
|
89
|
+
*
|
|
90
|
+
* Returns `this` for chaining:
|
|
91
|
+
* ```ts
|
|
92
|
+
* handler
|
|
93
|
+
* .registerPrecondition(PremiumOnly)
|
|
94
|
+
* .registerPrecondition(HasRole)
|
|
95
|
+
* .init();
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
public registerPrecondition(precondition: Precondition): this {
|
|
99
|
+
if (this.initialized) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Cannot register precondition "${precondition.name}" after init() has been called.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (this.preconditionRegistry.has(precondition.name)) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`Precondition "${precondition.name}" is already registered.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this.preconditionRegistry.set(precondition.name, precondition);
|
|
112
|
+
return this;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
public async init(): Promise<void> {
|
|
116
|
+
if (this.initialized) {
|
|
117
|
+
throw new Error("CommandHandler.init() was called more than once.");
|
|
118
|
+
}
|
|
119
|
+
this.initialized = true;
|
|
120
|
+
|
|
121
|
+
const { commands } = await loadCommandsFromDisk({
|
|
122
|
+
commandsDir: this.commandsDir,
|
|
123
|
+
extensions: this.extensions,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
for (const [name, cmd] of commands) {
|
|
127
|
+
this.commands.set(name, cmd);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this.attachedListener = attachInteractionListener({
|
|
131
|
+
client: this.client,
|
|
132
|
+
commands: this.commands,
|
|
133
|
+
ownerIds: this.ownerIds,
|
|
134
|
+
cooldowns: this.cooldowns,
|
|
135
|
+
preconditionRegistry: this.preconditionRegistry,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (this.registerConfig) {
|
|
139
|
+
await registerCommands({
|
|
140
|
+
token: this.registerConfig.token,
|
|
141
|
+
applicationId: this.registerConfig.applicationId,
|
|
142
|
+
where: this.registerConfig.where,
|
|
143
|
+
commandJson: this.toRegistrationJson(),
|
|
144
|
+
cache: this.registrationCache,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
public detach(): void {
|
|
150
|
+
if (!this.attachedListener) return;
|
|
151
|
+
|
|
152
|
+
this.client.off(
|
|
153
|
+
this.attachedListener.eventName,
|
|
154
|
+
this.attachedListener.fn as never,
|
|
155
|
+
);
|
|
156
|
+
this.attachedListener = undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Serialise commands to plain JSON for Discord registration.
|
|
161
|
+
*
|
|
162
|
+
* Permissions are applied to the serialised JSON object rather than the
|
|
163
|
+
* builder so we never mutate the in-memory command store as a side effect.
|
|
164
|
+
*
|
|
165
|
+
* Commands are sorted by name before serialisation so the hash is stable
|
|
166
|
+
* regardless of the order files are discovered on disk — without this, adding
|
|
167
|
+
* a command that glob-sorts before an existing one changes the array order,
|
|
168
|
+
* which changes the hash and can cause spurious re-registrations or missed
|
|
169
|
+
* registrations depending on the cache state.
|
|
170
|
+
*/
|
|
171
|
+
public toRegistrationJson(): unknown[] {
|
|
172
|
+
const out: Array<Record<string, unknown>> = [];
|
|
173
|
+
|
|
174
|
+
for (const cmd of this.commands.values()) {
|
|
175
|
+
const json = cmd.data.toJSON() as unknown as Record<string, unknown>;
|
|
176
|
+
|
|
177
|
+
const perms = cmd.meta?.defaultMemberPermissions;
|
|
178
|
+
|
|
179
|
+
if (perms && perms.length > 0) {
|
|
180
|
+
const bitfield = new PermissionsBitField(perms).bitfield;
|
|
181
|
+
json.default_member_permissions = String(bitfield);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
out.push(json);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Sort by name for a stable hash across different load orders
|
|
188
|
+
return out.sort((a, b) =>
|
|
189
|
+
String(a.name ?? "").localeCompare(String(b.name ?? "")),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export class UserError extends Error {
|
|
2
|
+
public readonly ephemeral: boolean;
|
|
3
|
+
|
|
4
|
+
constructor(message: string, options?: { ephemeral?: boolean }) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "UserError";
|
|
7
|
+
this.ephemeral = options?.ephemeral ?? true;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isUserError(err: unknown): err is UserError {
|
|
12
|
+
return err instanceof UserError;
|
|
13
|
+
}
|