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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
import { Collection } from "discord.js";
|
|
3
|
+
import { logger } from "../../../utils";
|
|
4
|
+
import type { ContextMenuCommand } from "../../@types/contextMenu";
|
|
5
|
+
import { discoverModuleFiles } from "../../utils/files";
|
|
6
|
+
|
|
7
|
+
export type LoadedContextMenus = Collection<string, ContextMenuCommand>;
|
|
8
|
+
|
|
9
|
+
function isContextMenuModule(mod: unknown): mod is ContextMenuCommand {
|
|
10
|
+
if (!mod || typeof mod !== "object") return false;
|
|
11
|
+
|
|
12
|
+
const m = mod as Partial<ContextMenuCommand>;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
typeof m.execute === "function" &&
|
|
16
|
+
typeof m.data === "object" &&
|
|
17
|
+
typeof m.data?.toJSON === "function" &&
|
|
18
|
+
typeof (m.data as { name?: unknown }).name === "string" &&
|
|
19
|
+
((m.data as { name?: unknown }).name as string).length > 0 &&
|
|
20
|
+
(m.type === "user" || m.type === "message")
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function loadContextMenusFromDisk(options: {
|
|
25
|
+
contextMenusDir: string;
|
|
26
|
+
extensions: string[];
|
|
27
|
+
}): Promise<LoadedContextMenus> {
|
|
28
|
+
const { rootDir, files } = await discoverModuleFiles({
|
|
29
|
+
dir: options.contextMenusDir,
|
|
30
|
+
extensions: options.extensions,
|
|
31
|
+
});
|
|
32
|
+
const commands = new Collection<string, ContextMenuCommand>();
|
|
33
|
+
let loaded = 0;
|
|
34
|
+
|
|
35
|
+
for (const filePath of files) {
|
|
36
|
+
const fileUrl = pathToFileURL(filePath).href;
|
|
37
|
+
const imported = await import(fileUrl);
|
|
38
|
+
const cmd = imported.default;
|
|
39
|
+
|
|
40
|
+
if (!isContextMenuModule(cmd)) {
|
|
41
|
+
logger.warn(`Skipping invalid context menu module: ${filePath}`);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const name = cmd.data.name;
|
|
46
|
+
|
|
47
|
+
if (commands.has(name)) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Duplicate context menu name "${name}" detected: ${filePath}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
commands.set(name, cmd);
|
|
54
|
+
loaded += 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
logger.info(`Loaded ${loaded} context menu command(s) from ${rootDir}`);
|
|
58
|
+
return commands;
|
|
59
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MessageContextMenuCommandInteraction,
|
|
3
|
+
UserContextMenuCommandInteraction,
|
|
4
|
+
} from "discord.js";
|
|
5
|
+
import type { ClientClass } from "../../../structure/Client";
|
|
6
|
+
import type { ContextMenuMeta } from "../../@types/contextMenu";
|
|
7
|
+
import type {
|
|
8
|
+
Precondition,
|
|
9
|
+
PreconditionResult,
|
|
10
|
+
} from "../../@types/precondition";
|
|
11
|
+
import type { CooldownStore } from "../../command/functions/cooldowns";
|
|
12
|
+
import { BotPermissions } from "../../command/functions/preconditions/BotPermissions";
|
|
13
|
+
import { Cooldown } from "../../command/functions/preconditions/Cooldown";
|
|
14
|
+
import { GuildOnly } from "../../command/functions/preconditions/GuildOnly";
|
|
15
|
+
import { OwnerOnly } from "../../command/functions/preconditions/OwnerOnly";
|
|
16
|
+
import { UserPermissions } from "../../command/functions/preconditions/UserPermissions";
|
|
17
|
+
|
|
18
|
+
export type ContextMenuPreconditionContext = {
|
|
19
|
+
interaction:
|
|
20
|
+
| UserContextMenuCommandInteraction
|
|
21
|
+
| MessageContextMenuCommandInteraction;
|
|
22
|
+
client: ClientClass;
|
|
23
|
+
meta: ContextMenuMeta;
|
|
24
|
+
ownerIds: Set<string>;
|
|
25
|
+
cooldowns: CooldownStore;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ContextMenuPreconditionFailure = {
|
|
29
|
+
message: string;
|
|
30
|
+
ephemeral: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type RunContextMenuPreconditionsResult =
|
|
34
|
+
| { ok: true }
|
|
35
|
+
| { ok: false; failure: ContextMenuPreconditionFailure };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Built-in preconditions run for every context menu invocation.
|
|
39
|
+
* Reuses the exact same objects as the slash command path so any fix
|
|
40
|
+
* in a built-in applies automatically to both interaction types.
|
|
41
|
+
*/
|
|
42
|
+
const BUILT_INS: Precondition[] = [
|
|
43
|
+
OwnerOnly,
|
|
44
|
+
GuildOnly,
|
|
45
|
+
Cooldown,
|
|
46
|
+
UserPermissions,
|
|
47
|
+
BotPermissions,
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Runs built-in guards and any custom preconditions for a context menu invocation.
|
|
52
|
+
*
|
|
53
|
+
* Previously this file reimplemented all built-in logic inline, meaning a bug
|
|
54
|
+
* fix in e.g. Cooldown.ts would not apply here. Now it delegates to the shared
|
|
55
|
+
* precondition objects directly.
|
|
56
|
+
*/
|
|
57
|
+
export async function runContextMenuPreconditions(options: {
|
|
58
|
+
ctx: ContextMenuPreconditionContext;
|
|
59
|
+
registry: Map<string, Precondition>;
|
|
60
|
+
}): Promise<RunContextMenuPreconditionsResult> {
|
|
61
|
+
const { ctx, registry } = options;
|
|
62
|
+
|
|
63
|
+
// Run built-ins first, in the same order as the slash command path
|
|
64
|
+
for (const precondition of BUILT_INS) {
|
|
65
|
+
const result: PreconditionResult = await precondition.run({
|
|
66
|
+
interaction: ctx.interaction as never,
|
|
67
|
+
client: ctx.client,
|
|
68
|
+
meta: ctx.meta as never,
|
|
69
|
+
ownerIds: ctx.ownerIds,
|
|
70
|
+
cooldowns: ctx.cooldowns,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (!result.ok) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
failure: {
|
|
77
|
+
message: result.message,
|
|
78
|
+
ephemeral: result.ephemeral ?? true,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Run custom preconditions declared on this context menu command
|
|
85
|
+
for (const name of ctx.meta.preconditions ?? []) {
|
|
86
|
+
const precondition = registry.get(name);
|
|
87
|
+
|
|
88
|
+
if (!precondition) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Precondition "${name}" is referenced by context menu "${ctx.interaction.commandName}" but was never registered.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const result: PreconditionResult = await precondition.run({
|
|
95
|
+
interaction: ctx.interaction as never,
|
|
96
|
+
client: ctx.client,
|
|
97
|
+
meta: ctx.meta as never,
|
|
98
|
+
ownerIds: ctx.ownerIds,
|
|
99
|
+
cooldowns: ctx.cooldowns,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (!result.ok) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
failure: {
|
|
106
|
+
message: result.message,
|
|
107
|
+
ephemeral: result.ephemeral ?? true,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { ok: true };
|
|
114
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
ContextMenuCommand,
|
|
3
|
+
ContextMenuMeta,
|
|
4
|
+
MessageContextMenuCommand,
|
|
5
|
+
UserContextMenuCommand,
|
|
6
|
+
} from "../@types/contextMenu";
|
|
7
|
+
export {
|
|
8
|
+
defineMessageContextMenu,
|
|
9
|
+
defineUserContextMenu,
|
|
10
|
+
} from "../@types/contextMenu";
|
|
11
|
+
export type { ContextMenuHandlerOptions } from "./ContextMenuHandler";
|
|
12
|
+
export { ContextMenuHandler } from "./ContextMenuHandler";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ClientClass } from "../../structure/Client";
|
|
2
|
+
import type { AttachedHandler } from "./functions/attachEvents";
|
|
3
|
+
import { attachEvents } from "./functions/attachEvents";
|
|
4
|
+
import { type LoadedEvents, loadEventsFromDisk } from "./functions/loadEvents";
|
|
5
|
+
|
|
6
|
+
export type EventHandlerOptions = {
|
|
7
|
+
client: ClientClass;
|
|
8
|
+
eventsDir: string;
|
|
9
|
+
extensions?: string[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export class EventHandler {
|
|
13
|
+
private readonly client: ClientClass;
|
|
14
|
+
private readonly eventsDir: string;
|
|
15
|
+
private readonly extensions: string[];
|
|
16
|
+
|
|
17
|
+
public events: LoadedEvents = new Map();
|
|
18
|
+
public attachedHandlers: AttachedHandler[] = [];
|
|
19
|
+
|
|
20
|
+
private initialized = false;
|
|
21
|
+
|
|
22
|
+
constructor(options: EventHandlerOptions) {
|
|
23
|
+
this.client = options.client;
|
|
24
|
+
this.eventsDir = options.eventsDir;
|
|
25
|
+
this.extensions = options.extensions ?? [".ts"];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public async init(): Promise<void> {
|
|
29
|
+
if (this.initialized) {
|
|
30
|
+
throw new Error("EventHandler.init() was called more than once.");
|
|
31
|
+
}
|
|
32
|
+
this.initialized = true;
|
|
33
|
+
|
|
34
|
+
this.events = await loadEventsFromDisk({
|
|
35
|
+
eventsDir: this.eventsDir,
|
|
36
|
+
extensions: this.extensions,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Store the return value so callers can detach listeners later via detachEvents()
|
|
40
|
+
this.attachedHandlers = attachEvents({
|
|
41
|
+
client: this.client,
|
|
42
|
+
events: this.events,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { type ClientEvents, Events } from "discord.js";
|
|
2
|
+
import type { ClientClass } from "../../../structure/Client";
|
|
3
|
+
import { logger } from "../../../utils";
|
|
4
|
+
import type { LoadedEvents, LoadedListener } from "./loadEvents";
|
|
5
|
+
|
|
6
|
+
export type AttachedHandler = {
|
|
7
|
+
eventName: keyof ClientEvents;
|
|
8
|
+
once: boolean;
|
|
9
|
+
fn: (...args: unknown[]) => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const KNOWN_EVENT_NAMES = new Set<string>(Object.values(Events));
|
|
13
|
+
|
|
14
|
+
async function runListeners<K extends keyof ClientEvents>(options: {
|
|
15
|
+
client: ClientClass;
|
|
16
|
+
eventName: K;
|
|
17
|
+
listeners: LoadedListener[];
|
|
18
|
+
args: ClientEvents[K];
|
|
19
|
+
}): Promise<void> {
|
|
20
|
+
const { client, eventName, listeners, args } = options;
|
|
21
|
+
|
|
22
|
+
for (const listener of listeners) {
|
|
23
|
+
try {
|
|
24
|
+
await listener.execute({ client, args });
|
|
25
|
+
} catch (err) {
|
|
26
|
+
logger.error(
|
|
27
|
+
`Error in event "${String(eventName)}" listener (${listener.filePath})`,
|
|
28
|
+
err,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function attachEvents(options: {
|
|
35
|
+
client: ClientClass;
|
|
36
|
+
events: LoadedEvents;
|
|
37
|
+
}): AttachedHandler[] {
|
|
38
|
+
const { client, events } = options;
|
|
39
|
+
|
|
40
|
+
const attached: AttachedHandler[] = [];
|
|
41
|
+
|
|
42
|
+
for (const [eventName, listeners] of events) {
|
|
43
|
+
if (!KNOWN_EVENT_NAMES.has(String(eventName))) {
|
|
44
|
+
logger.warn(
|
|
45
|
+
`Unknown event folder "${String(
|
|
46
|
+
eventName,
|
|
47
|
+
)}". Check spelling/casing. Files inside will still be attached, but may never fire.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const onceListeners = listeners.filter((l) => l.once);
|
|
52
|
+
const onListeners = listeners.filter((l) => !l.once);
|
|
53
|
+
|
|
54
|
+
if (onListeners.length > 0) {
|
|
55
|
+
const fn = (...args: unknown[]) => {
|
|
56
|
+
void runListeners({
|
|
57
|
+
client,
|
|
58
|
+
eventName: eventName as never,
|
|
59
|
+
listeners: onListeners,
|
|
60
|
+
args: args as never,
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
client.on(eventName, fn as never);
|
|
65
|
+
attached.push({ eventName, once: false, fn });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (onceListeners.length > 0) {
|
|
69
|
+
const fn = (...args: unknown[]) => {
|
|
70
|
+
void runListeners({
|
|
71
|
+
client,
|
|
72
|
+
eventName: eventName as never,
|
|
73
|
+
listeners: onceListeners,
|
|
74
|
+
args: args as never,
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
client.once(eventName, fn as never);
|
|
79
|
+
attached.push({ eventName, once: true, fn });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return attached;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function detachEvents(options: {
|
|
87
|
+
client: ClientClass;
|
|
88
|
+
attached: AttachedHandler[];
|
|
89
|
+
}): void {
|
|
90
|
+
const { client, attached } = options;
|
|
91
|
+
|
|
92
|
+
for (const h of attached) {
|
|
93
|
+
client.off(h.eventName, h.fn as never);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { logger } from "../../../utils";
|
|
4
|
+
import type { EventListenerModule, EventName } from "../../@types/event";
|
|
5
|
+
import { discoverModuleFiles } from "../../utils/files";
|
|
6
|
+
|
|
7
|
+
export type LoadedListener = {
|
|
8
|
+
filePath: string;
|
|
9
|
+
once: boolean;
|
|
10
|
+
order: number;
|
|
11
|
+
execute: EventListenerModule["execute"];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type LoadedEvents = Map<EventName, LoadedListener[]>;
|
|
15
|
+
|
|
16
|
+
function isListenerModule(mod: unknown): mod is EventListenerModule {
|
|
17
|
+
if (!mod || typeof mod !== "object") return false;
|
|
18
|
+
const m = mod as Partial<EventListenerModule>;
|
|
19
|
+
return typeof m.execute === "function";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Derive the event name from the file path.
|
|
24
|
+
*
|
|
25
|
+
* Normalises path separators before splitting so behaviour is consistent on
|
|
26
|
+
* both POSIX and Windows (where path.sep would be "\\").
|
|
27
|
+
*
|
|
28
|
+
* Expected structure: <eventsDir>/<eventName>/[...].ts
|
|
29
|
+
*/
|
|
30
|
+
function getEventNameFromFilePath(
|
|
31
|
+
eventsDirAbs: string,
|
|
32
|
+
filePath: string,
|
|
33
|
+
): string | null {
|
|
34
|
+
const rel = path.relative(eventsDirAbs, filePath).replace(/\\/g, "/"); // normalise Windows backslashes
|
|
35
|
+
|
|
36
|
+
const parts = rel.split("/").filter(Boolean);
|
|
37
|
+
return parts[0] ?? null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function loadEventsFromDisk(options: {
|
|
41
|
+
eventsDir: string;
|
|
42
|
+
extensions: string[];
|
|
43
|
+
}): Promise<LoadedEvents> {
|
|
44
|
+
const { rootDir, files } = await discoverModuleFiles({
|
|
45
|
+
dir: options.eventsDir,
|
|
46
|
+
extensions: options.extensions,
|
|
47
|
+
});
|
|
48
|
+
const events: LoadedEvents = new Map();
|
|
49
|
+
let loaded = 0;
|
|
50
|
+
|
|
51
|
+
for (const filePath of files) {
|
|
52
|
+
const eventNameRaw = getEventNameFromFilePath(rootDir, filePath);
|
|
53
|
+
if (!eventNameRaw) continue;
|
|
54
|
+
|
|
55
|
+
const fileUrl = pathToFileURL(filePath).href;
|
|
56
|
+
const imported = await import(fileUrl);
|
|
57
|
+
const mod = imported.default;
|
|
58
|
+
|
|
59
|
+
if (!isListenerModule(mod)) {
|
|
60
|
+
logger.warn(`Skipping invalid event listener module: ${filePath}`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (mod.enabled === false) continue;
|
|
65
|
+
|
|
66
|
+
const eventName = eventNameRaw as EventName;
|
|
67
|
+
|
|
68
|
+
const listener: LoadedListener = {
|
|
69
|
+
filePath,
|
|
70
|
+
once: mod.once ?? false,
|
|
71
|
+
order: mod.order ?? 0,
|
|
72
|
+
execute: mod.execute,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const arr = events.get(eventName) ?? [];
|
|
76
|
+
arr.push(listener);
|
|
77
|
+
events.set(eventName, arr);
|
|
78
|
+
|
|
79
|
+
loaded += 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const [eventName, listeners] of events) {
|
|
83
|
+
listeners.sort((a, b) => {
|
|
84
|
+
if (a.order !== b.order) return a.order - b.order;
|
|
85
|
+
return a.filePath.localeCompare(b.filePath);
|
|
86
|
+
});
|
|
87
|
+
events.set(eventName, listeners);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
logger.info(`Loaded ${loaded} event listener file(s) from ${rootDir}`);
|
|
91
|
+
|
|
92
|
+
return events;
|
|
93
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { ClientClass } from "../../structure/Client";
|
|
2
|
+
import { logger } from "../../utils";
|
|
3
|
+
import type { Precondition } from "../@types/precondition";
|
|
4
|
+
import {
|
|
5
|
+
CommandHandler,
|
|
6
|
+
type CommandHandlerOptions,
|
|
7
|
+
} from "../command/CommandHandler";
|
|
8
|
+
import {
|
|
9
|
+
ContextMenuHandler,
|
|
10
|
+
type ContextMenuHandlerOptions,
|
|
11
|
+
} from "../context/ContextMenuHandler";
|
|
12
|
+
import { EventHandler, type EventHandlerOptions } from "../events/EventHandler";
|
|
13
|
+
import { detachEvents } from "../events/functions/attachEvents";
|
|
14
|
+
import { TaskHandler, type TaskHandlerOptions } from "../tasks/TaskHandler";
|
|
15
|
+
import { registerCommands } from "../command/functions/registerCommands";
|
|
16
|
+
import {
|
|
17
|
+
combineRegistrationPlans,
|
|
18
|
+
type HandlerRegistrationPlan,
|
|
19
|
+
} from "./registrationPlans";
|
|
20
|
+
|
|
21
|
+
// Options for each handler are the same as their standalone versions,
|
|
22
|
+
// minus `client` which is provided once at the top level.
|
|
23
|
+
type WithoutClient<T> = Omit<T, "client">;
|
|
24
|
+
|
|
25
|
+
export type HandlerManagerOptions = {
|
|
26
|
+
client: ClientClass;
|
|
27
|
+
|
|
28
|
+
commands?: WithoutClient<CommandHandlerOptions>;
|
|
29
|
+
contextMenus?: WithoutClient<ContextMenuHandlerOptions>;
|
|
30
|
+
events?: WithoutClient<EventHandlerOptions>;
|
|
31
|
+
tasks?: WithoutClient<TaskHandlerOptions>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* If true (default), registers SIGINT and SIGTERM handlers for graceful
|
|
35
|
+
* shutdown. Set to false if you manage process signals yourself.
|
|
36
|
+
*/
|
|
37
|
+
handleShutdownSignals?: boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export class HandlerManager {
|
|
41
|
+
private readonly client: ClientClass;
|
|
42
|
+
private readonly commandsConfig?: WithoutClient<CommandHandlerOptions>;
|
|
43
|
+
private readonly contextMenusConfig?: WithoutClient<ContextMenuHandlerOptions>;
|
|
44
|
+
private readonly eventsConfig?: WithoutClient<EventHandlerOptions>;
|
|
45
|
+
private readonly tasksConfig?: WithoutClient<TaskHandlerOptions>;
|
|
46
|
+
private readonly shutdownSignals: boolean;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Shared precondition registry passed to both CommandHandler and
|
|
50
|
+
* ContextMenuHandler. Register preconditions here once and they are
|
|
51
|
+
* available to both slash commands and context menus.
|
|
52
|
+
*/
|
|
53
|
+
public readonly preconditions = new Map<string, Precondition>();
|
|
54
|
+
|
|
55
|
+
public commandHandler?: CommandHandler;
|
|
56
|
+
public contextMenuHandler?: ContextMenuHandler;
|
|
57
|
+
public eventHandler?: EventHandler;
|
|
58
|
+
public taskHandler?: TaskHandler;
|
|
59
|
+
|
|
60
|
+
private initialized = false;
|
|
61
|
+
|
|
62
|
+
constructor(options: HandlerManagerOptions) {
|
|
63
|
+
this.client = options.client;
|
|
64
|
+
this.commandsConfig = options.commands;
|
|
65
|
+
this.contextMenusConfig = options.contextMenus;
|
|
66
|
+
this.eventsConfig = options.events;
|
|
67
|
+
this.tasksConfig = options.tasks;
|
|
68
|
+
this.shutdownSignals = options.handleShutdownSignals !== false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Register a custom precondition. Must be called before `init()`.
|
|
73
|
+
* Available to both slash commands and context menus automatically.
|
|
74
|
+
* Returns `this` for chaining.
|
|
75
|
+
*/
|
|
76
|
+
public registerPrecondition(precondition: Precondition): this {
|
|
77
|
+
if (this.initialized) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Cannot register precondition "${precondition.name}" after init() has been called.`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (this.preconditions.has(precondition.name)) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Precondition "${precondition.name}" is already registered.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.preconditions.set(precondition.name, precondition);
|
|
90
|
+
return this;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Initialises all configured handlers in the correct order:
|
|
95
|
+
* 1. Events — attach listeners first so nothing is missed
|
|
96
|
+
* 2. Commands — load slash commands and attach interaction routing
|
|
97
|
+
* 3. Context menus — load context menus and attach interaction routing
|
|
98
|
+
* 4. Registration — register all application commands in one payload per scope
|
|
99
|
+
* 5. Tasks — start scheduled jobs last (bot should be ready)
|
|
100
|
+
*/
|
|
101
|
+
public async init(): Promise<void> {
|
|
102
|
+
if (this.initialized) {
|
|
103
|
+
throw new Error("HandlerManager.init() was called more than once.");
|
|
104
|
+
}
|
|
105
|
+
this.initialized = true;
|
|
106
|
+
|
|
107
|
+
const { client } = this;
|
|
108
|
+
|
|
109
|
+
logger.info("HandlerManager initialising...");
|
|
110
|
+
const startedAt = Date.now();
|
|
111
|
+
|
|
112
|
+
// 1. Events
|
|
113
|
+
if (this.eventsConfig) {
|
|
114
|
+
this.eventHandler = new EventHandler({
|
|
115
|
+
...this.eventsConfig,
|
|
116
|
+
client,
|
|
117
|
+
});
|
|
118
|
+
await this.eventHandler.init();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 2. Commands
|
|
122
|
+
if (this.commandsConfig) {
|
|
123
|
+
this.commandHandler = new CommandHandler({
|
|
124
|
+
...this.commandsConfig,
|
|
125
|
+
client,
|
|
126
|
+
register: undefined,
|
|
127
|
+
preconditionRegistry: this.preconditions,
|
|
128
|
+
});
|
|
129
|
+
await this.commandHandler.init();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 3. Context menus
|
|
133
|
+
if (this.contextMenusConfig) {
|
|
134
|
+
this.contextMenuHandler = new ContextMenuHandler({
|
|
135
|
+
...this.contextMenusConfig,
|
|
136
|
+
client,
|
|
137
|
+
register: undefined,
|
|
138
|
+
preconditionRegistry: this.preconditions,
|
|
139
|
+
});
|
|
140
|
+
await this.contextMenuHandler.init();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await this.registerApplicationCommands();
|
|
144
|
+
|
|
145
|
+
// 4. Tasks
|
|
146
|
+
if (this.tasksConfig) {
|
|
147
|
+
this.taskHandler = new TaskHandler({
|
|
148
|
+
...this.tasksConfig,
|
|
149
|
+
client,
|
|
150
|
+
});
|
|
151
|
+
await this.taskHandler.init();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
logger.info(`HandlerManager ready in ${Date.now() - startedAt}ms.`);
|
|
155
|
+
|
|
156
|
+
if (this.shutdownSignals) {
|
|
157
|
+
this.registerShutdownSignals();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Gracefully tears down all handlers:
|
|
163
|
+
* - Cancels all scheduled tasks
|
|
164
|
+
* - Detaches all event listeners
|
|
165
|
+
*
|
|
166
|
+
* Safe to call multiple times.
|
|
167
|
+
*/
|
|
168
|
+
public shutdown(): void {
|
|
169
|
+
logger.info("HandlerManager shutting down...");
|
|
170
|
+
|
|
171
|
+
this.taskHandler?.cancelAll();
|
|
172
|
+
this.commandHandler?.detach();
|
|
173
|
+
this.contextMenuHandler?.detach();
|
|
174
|
+
|
|
175
|
+
if (this.eventHandler) {
|
|
176
|
+
detachEvents({
|
|
177
|
+
client: this.client,
|
|
178
|
+
attached: this.eventHandler.attachedHandlers,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
logger.info("HandlerManager shutdown complete.");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private registerShutdownSignals(): void {
|
|
186
|
+
const handler = (signal: string) => {
|
|
187
|
+
logger.info(`Received ${signal}. Shutting down...`);
|
|
188
|
+
this.shutdown();
|
|
189
|
+
process.exit(0);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
process.once("SIGINT", () => handler("SIGINT"));
|
|
193
|
+
process.once("SIGTERM", () => handler("SIGTERM"));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private async registerApplicationCommands(): Promise<void> {
|
|
197
|
+
const plans: HandlerRegistrationPlan[] = [];
|
|
198
|
+
|
|
199
|
+
if (this.commandsConfig?.register && this.commandHandler) {
|
|
200
|
+
plans.push({
|
|
201
|
+
register: this.commandsConfig.register,
|
|
202
|
+
commandJson: this.commandHandler.toRegistrationJson(),
|
|
203
|
+
cache: this.commandsConfig.registrationCache ?? true,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (this.contextMenusConfig?.register && this.contextMenuHandler) {
|
|
208
|
+
plans.push({
|
|
209
|
+
register: this.contextMenusConfig.register,
|
|
210
|
+
commandJson: this.contextMenuHandler.toRegistrationJson(),
|
|
211
|
+
cache: this.contextMenusConfig.registrationCache ?? true,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for (const plan of combineRegistrationPlans(plans)) {
|
|
216
|
+
await registerCommands({
|
|
217
|
+
token: plan.register.token,
|
|
218
|
+
applicationId: plan.register.applicationId,
|
|
219
|
+
where: plan.register.where,
|
|
220
|
+
commandJson: plan.commandJson,
|
|
221
|
+
cache: plan.cache,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|