Skip to content

Commit 1fe74b6

Browse files
committed
feat: app command handler wip
1 parent d5531d1 commit 1fe74b6

File tree

18 files changed

+1344
-0
lines changed

18 files changed

+1344
-0
lines changed

packages/commandkit/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"@babel/parser": "^7.26.5",
3535
"@babel/traverse": "^7.26.5",
3636
"@babel/types": "^7.26.5",
37+
"@commandkit/router": "workspace:*",
3738
"commander": "^12.1.0",
3839
"dotenv": "^16.4.7",
3940
"ms": "^2.1.3",

packages/commandkit/src/CommandKit.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ import { CacheProvider } from './cache/CacheProvider';
1515
import { MemoryCache } from './cache/MemoryCache';
1616
import { createElement, Fragment } from './components';
1717
import { EventInterceptor } from './components/common/EventInterceptor';
18+
import { Locale } from 'discord.js';
19+
import { DefaultLocalizationStrategy } from './app/i18n/DefaultLocalizationStrategy';
20+
import { findAppDirectory } from './utils/utilities';
21+
import { join } from 'node:path';
22+
import { CommandsRouter, EventsRouter } from '@commandkit/router';
23+
import { COMMANDKIT_IS_DEV } from './utils/constants';
24+
import { AppCommandHandler } from './app/command-handler/AppCommandHandler';
1825

1926
export class CommandKit extends EventEmitter {
2027
#data: CommandKitData;
@@ -23,6 +30,15 @@ export class CommandKit extends EventEmitter {
2330
public static readonly createElement = createElement;
2431
public static readonly Fragment = Fragment;
2532

33+
public readonly config = {
34+
defaultLocale: Locale.EnglishUS,
35+
localizationStrategy: new DefaultLocalizationStrategy(this),
36+
};
37+
38+
public commandsRouter!: CommandsRouter;
39+
public eventsRouter!: EventsRouter;
40+
public appCommandsHandler = new AppCommandHandler(this);
41+
2642
static instance: CommandKit | undefined = undefined;
2743

2844
/**
@@ -112,6 +128,8 @@ export class CommandKit extends EventEmitter {
112128
* (Private) Initialize CommandKit.
113129
*/
114130
async #init() {
131+
await this.#initApp();
132+
115133
// <!-- Setup event handler -->
116134
if (this.#data.eventsPath) {
117135
const eventHandler = new EventHandler({
@@ -156,6 +174,45 @@ export class CommandKit extends EventEmitter {
156174
}
157175
}
158176

177+
async #initApp() {
178+
const appDir = this.getAppDirectory();
179+
if (!appDir) return;
180+
181+
const commandsPath = this.getPath('commands')!;
182+
const events = this.getPath('events')!;
183+
184+
this.commandsRouter = new CommandsRouter({
185+
entrypoint: commandsPath,
186+
});
187+
188+
this.eventsRouter = new EventsRouter({
189+
entrypoint: events,
190+
});
191+
192+
await this.#initEvents();
193+
await this.#initCommands();
194+
}
195+
196+
async #initCommands() {
197+
if (this.commandsRouter.isValidPath()) {
198+
await this.commandsRouter.scan();
199+
}
200+
}
201+
202+
async #initEvents() {
203+
if (this.eventsRouter.isValidPath()) {
204+
await this.eventsRouter.scan();
205+
}
206+
207+
if (!this.#data.eventHandler) return;
208+
209+
for (const event of Object.values(this.eventsRouter.toJSON())) {
210+
this.#data.eventHandler.registerExternal(event);
211+
}
212+
213+
this.#data.eventHandler.resyncListeners();
214+
}
215+
159216
/**
160217
* Updates application commands with the latest from "commandsPath".
161218
*/
@@ -251,4 +308,30 @@ export class CommandKit extends EventEmitter {
251308
decrementClientListenersCount() {
252309
this.#data.client.setMaxListeners(this.#data.client.getMaxListeners() - 1);
253310
}
311+
312+
/**
313+
* Path to the app directory. Returns `null` if not found.
314+
* The lookup order is:
315+
* - `./app`
316+
* - `./src/app`
317+
*/
318+
getAppDirectory() {
319+
return findAppDirectory();
320+
}
321+
322+
getPath(to: 'locales' | 'commands' | 'events') {
323+
const appDir = this.getAppDirectory();
324+
if (!appDir) return null;
325+
326+
switch (to) {
327+
case 'locales':
328+
return join(appDir, 'locales');
329+
case 'commands':
330+
return join(appDir, 'commands');
331+
case 'events':
332+
return join(appDir, 'events');
333+
default:
334+
return to satisfies never;
335+
}
336+
}
254337
}
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { ParsedCommand, ParsedMiddleware } from '@commandkit/router';
2+
import type { CommandKit } from '../../CommandKit';
3+
import {
4+
Awaitable,
5+
ContextMenuCommandBuilder,
6+
Locale,
7+
SlashCommandBuilder,
8+
} from 'discord.js';
9+
import { Context } from '../commands/Context';
10+
import { toFileURL } from '../../utils/resolve-file-url';
11+
import { TranslatableCommandOptions } from '../i18n/Translation';
12+
13+
interface AppCommand {
14+
options: SlashCommandBuilder | Record<string, any>;
15+
}
16+
17+
interface AppCommandMiddleware {
18+
beforeExecute: (ctx: Context) => Awaitable<unknown>;
19+
afterExecute: (ctx: Context) => Awaitable<unknown>;
20+
}
21+
22+
interface LoadedCommand {
23+
command: ParsedCommand;
24+
data: AppCommand;
25+
}
26+
27+
interface LoadedMiddleware {
28+
middleware: ParsedMiddleware;
29+
data: AppCommandMiddleware;
30+
}
31+
32+
type CommandBuilderLike =
33+
| SlashCommandBuilder
34+
| ContextMenuCommandBuilder
35+
| Record<string, any>;
36+
37+
const commandDataSchema = {
38+
command: (c: unknown) =>
39+
c instanceof SlashCommandBuilder ||
40+
c instanceof ContextMenuCommandBuilder ||
41+
(c && typeof c === 'object'),
42+
chatInput: (c: unknown) => typeof c === 'function',
43+
autocomplete: (c: unknown) => typeof c === 'function',
44+
message: (c: unknown) => typeof c === 'function',
45+
messageContextMenu: (c: unknown) => typeof c === 'function',
46+
userContextMenu: (c: unknown) => typeof c === 'function',
47+
};
48+
49+
const middlewareDataSchema = {
50+
beforeExecute: (c: unknown) => typeof c === 'function',
51+
afterExecute: (c: unknown) => typeof c === 'function',
52+
};
53+
54+
export class AppCommandHandler {
55+
private loadedCommands = new Map<string, LoadedCommand>();
56+
private loadedMiddlewares = new Map<string, LoadedMiddleware>();
57+
58+
public constructor(public readonly commandkit: CommandKit) {}
59+
60+
public async prepareCommandRun(command: string) {
61+
const loadedCommand = this.loadedCommands.get(command);
62+
63+
if (!loadedCommand) return null;
64+
65+
return {
66+
command: loadedCommand,
67+
middlewares: loadedCommand.command.middlewares.map((m) =>
68+
this.loadedMiddlewares.get(m),
69+
),
70+
};
71+
}
72+
73+
public async loadCommands() {
74+
const commandsRouter = this.commandkit.commandsRouter;
75+
76+
if (!commandsRouter) {
77+
throw new Error('Commands router has not yet initialized');
78+
}
79+
80+
const { commands, middleware } = commandsRouter.getData();
81+
82+
for (const [id, md] of middleware) {
83+
const data = await import(`${toFileURL(md.fullPath)}?t=${Date.now()}`);
84+
85+
let handlerCount = 0;
86+
for (const [key, validator] of Object.entries(middlewareDataSchema)) {
87+
if (data[key] && !(await validator(data[key]))) {
88+
throw new Error(
89+
`Invalid export for middleware ${id}: ${key} does not match expected value`,
90+
);
91+
}
92+
93+
if (data[key]) handlerCount++;
94+
}
95+
96+
if (handlerCount === 0) {
97+
throw new Error(
98+
`Invalid export for middleware ${id}: at least one handler function must be provided`,
99+
);
100+
}
101+
102+
this.loadedMiddlewares.set(id, { middleware: md, data });
103+
}
104+
105+
for (const [name, command] of commands) {
106+
const data = await import(
107+
`${toFileURL(command.fullPath)}?t=${Date.now()}`
108+
);
109+
110+
if (!data.command) {
111+
throw new Error(
112+
`Invalid export for command ${name}: no command definition found`,
113+
);
114+
}
115+
116+
let handlerCount = 0;
117+
118+
for (const [key, validator] of Object.entries(commandDataSchema)) {
119+
if (key !== 'command' && data[key]) handlerCount++;
120+
if (data[key] && !(await validator(data[key]))) {
121+
throw new Error(
122+
`Invalid export for command ${name}: ${key} does not match expected value`,
123+
);
124+
}
125+
}
126+
127+
if (handlerCount === 0) {
128+
throw new Error(
129+
`Invalid export for command ${name}: at least one handler function must be provided`,
130+
);
131+
}
132+
133+
data.command = await this.applyLocalizations(data.command);
134+
135+
this.loadedCommands.set(name, { command, data });
136+
}
137+
}
138+
139+
public async applyLocalizations(command: CommandBuilderLike) {
140+
const localization = this.commandkit.config.localizationStrategy;
141+
142+
const validLocales = Object.values(Locale).filter(
143+
(v) => typeof v === 'string',
144+
);
145+
146+
for (const locale of validLocales) {
147+
const translation = await localization.locateTranslation(
148+
command.name,
149+
locale,
150+
);
151+
152+
if (!translation?.command) continue;
153+
154+
if (command instanceof SlashCommandBuilder) {
155+
if (translation.command.name) {
156+
command.setNameLocalization(locale, translation.command.name);
157+
}
158+
159+
if (translation.command.description) {
160+
command.setDescriptionLocalization(
161+
locale,
162+
translation.command.description,
163+
);
164+
}
165+
166+
const raw = command.toJSON();
167+
168+
if (raw.options?.length && translation.command.options?.length) {
169+
const opt = translation.command.options.slice();
170+
let o: TranslatableCommandOptions;
171+
172+
while ((o = opt.shift()!)) {
173+
raw.options?.forEach((option) => {
174+
if (option.name === o.ref) {
175+
if (option.name) {
176+
option.name_localizations ??= {};
177+
option.name_localizations[locale] = o.name;
178+
}
179+
180+
if (option.description) {
181+
option.description_localizations ??= {};
182+
option.description_localizations[locale] = o.description;
183+
}
184+
}
185+
});
186+
}
187+
}
188+
} else if (command instanceof ContextMenuCommandBuilder) {
189+
if (translation.command.name) {
190+
command.setNameLocalization(locale, translation.command.name);
191+
}
192+
193+
const raw = command.toJSON();
194+
195+
return raw;
196+
} else {
197+
command.name_localizations ??= {};
198+
command.name_localizations[locale] = translation.command.name;
199+
200+
if (command.description) {
201+
command.description_localizations ??= {};
202+
command.description_localizations[locale] =
203+
translation.command.description;
204+
}
205+
206+
if (command.options?.length && translation.command.options?.length) {
207+
const opt = translation.command.options.slice();
208+
let o: TranslatableCommandOptions;
209+
210+
while ((o = opt.shift()!)) {
211+
command.options.forEach((option: any) => {
212+
if (option.name === o.ref) {
213+
if (option.name) {
214+
option.name_localizations ??= {};
215+
option.name_localizations[locale] = o.name;
216+
}
217+
218+
if (option.description) {
219+
option.description_localizations ??= {};
220+
option.description_localizations[locale] = o.description;
221+
}
222+
}
223+
});
224+
}
225+
}
226+
}
227+
}
228+
}
229+
}

0 commit comments

Comments
 (0)