Skip to content

Commit bc38d1c

Browse files
committed
fix: commands router
1 parent 813aa49 commit bc38d1c

File tree

12 files changed

+652
-389
lines changed

12 files changed

+652
-389
lines changed

apps/test-bot/src/app/commands/random/texts/name/computer.ts renamed to apps/test-bot/src/app/commands/random/texts/computer.ts

File renamed without changes.

apps/test-bot/src/app/commands/random/texts/name/person.ts renamed to apps/test-bot/src/app/commands/random/texts/person.ts

File renamed without changes.

apps/test-bot/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"skipDefaultLibCheck": true,
1414
"allowJs": true,
1515
"alwaysStrict": false,
16-
"checkJs": false
16+
"checkJs": false,
17+
"types": ["commandkit-types"]
1718
},
1819
"include": ["src"]
1920
}

packages/commandkit/src/CommandKit.ts

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import { loadConfigFile } from './config/loader';
1818
import { COMMANDKIT_IS_DEV } from './utils/constants';
1919
import { registerDevHooks } from './utils/dev-hooks';
2020
import { writeFileSync } from 'node:fs';
21+
import { CommandKitEventsChannel } from './events/CommandKitEventsChannel';
22+
import { isRuntimePlugin } from './plugins';
23+
import { generateTypesPackage } from './utils/types-package';
24+
import { Logger } from './logger/Logger';
2125

2226
export interface CommandKitConfiguration {
2327
defaultLocale: Locale;
@@ -43,6 +47,7 @@ export class CommandKit extends EventEmitter {
4347
public readonly commandHandler = new AppCommandHandler(this);
4448
public readonly eventHandler = new AppEventsHandler(this);
4549
public readonly plugins: CommandKitPluginRuntime;
50+
public readonly events = new CommandKitEventsChannel(this);
4651

4752
static instance: CommandKit | undefined = undefined;
4853

@@ -99,9 +104,23 @@ export class CommandKit extends EventEmitter {
99104
* @param token The application token to connect to the discord gateway. If not provided, it will use the `TOKEN` or `DISCORD_TOKEN` environment variable. If set to `false`, it will not login.
100105
*/
101106
async start(token?: string | false) {
102-
await loadConfigFile();
103107
if (this.#started) return;
104108

109+
if (COMMANDKIT_IS_DEV) {
110+
try {
111+
registerDevHooks(this);
112+
113+
await generateTypesPackage();
114+
} catch (e) {
115+
// ignore
116+
if (process.env.COMMANDKIT_DEBUG_TYPEGEN) {
117+
Logger.error(e);
118+
}
119+
}
120+
}
121+
122+
await this.loadPlugins();
123+
105124
await this.#init();
106125

107126
this.incrementClientListenersCount();
@@ -114,13 +133,23 @@ export class CommandKit extends EventEmitter {
114133

115134
this.#started = true;
116135

117-
if (COMMANDKIT_IS_DEV) {
118-
registerDevHooks(this);
119-
}
120-
121136
await this.commandHandler.registrar.register();
122137
}
123138

139+
/**
140+
* Loads all the plugins.
141+
*/
142+
async loadPlugins() {
143+
const config = await loadConfigFile();
144+
const plugins = config.plugins.filter((p) => isRuntimePlugin(p));
145+
146+
if (!plugins.length) return;
147+
148+
for (const plugin of plugins) {
149+
await this.plugins.softRegisterPlugin(plugin);
150+
}
151+
}
152+
124153
/**
125154
* Whether or not the commandkit application has started.
126155
*/
@@ -200,8 +229,12 @@ export class CommandKit extends EventEmitter {
200229

201230
async #initCommands() {
202231
if (this.commandsRouter.isValidPath()) {
203-
const commands = await this.commandsRouter.scan();
204-
await writeFileSync('./commands.json', JSON.stringify(commands, null, 2));
232+
await this.commandsRouter.scan();
233+
234+
if (COMMANDKIT_IS_DEV) {
235+
const visual = this.commandsRouter.visualize();
236+
console.log(visual);
237+
}
205238
}
206239

207240
await this.commandHandler.loadCommands();

packages/commandkit/src/app/handlers/AppEventsHandler.ts

Lines changed: 103 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
import { CommandKit } from '../../CommandKit';
2+
import { ListenerFunction } from '../../events/CommandKitEventsChannel';
23
import { Logger } from '../../logger/Logger';
34
import { toFileURL } from '../../utils/resolve-file-url';
45
import { ParsedEvent } from '../router';
56

6-
export type EventListener = (...args: any[]) => any;
7+
export type EventListener = {
8+
handler: ListenerFunction;
9+
once: boolean;
10+
};
711

812
export interface LoadedEvent {
913
name: string;
1014
namespace: string | null;
1115
event: ParsedEvent;
1216
listeners: EventListener[];
1317
mainListener?: EventListener;
18+
executedOnceListeners?: Set<ListenerFunction>; // Track executed once listeners
1419
}
1520

1621
export class AppEventsHandler {
@@ -39,7 +44,10 @@ export class AppEventsHandler {
3944
);
4045
}
4146

42-
listeners.push(handler.default);
47+
listeners.push({
48+
handler: handler.default,
49+
once: !!handler.once,
50+
});
4351
}
4452

4553
const len = listeners.length;
@@ -76,37 +84,96 @@ export class AppEventsHandler {
7684
const client = this.commandkit.client;
7785

7886
for (const [key, data] of this.loadedEvents.entries()) {
79-
const { name, listeners, namespace, mainListener } = data;
80-
const main =
81-
mainListener ||
82-
(async (...args) => {
83-
for (const listener of listeners) {
84-
try {
85-
await listener(...args);
86-
} catch (e) {
87-
Logger.error(
88-
`Error handling event ${name}${
89-
namespace ? ` of namespace ${namespace}` : ''
90-
}`,
91-
e,
92-
);
93-
}
87+
const { name, listeners, namespace } = data;
88+
89+
// Separate listeners into "once" and "on" groups
90+
const onceListeners = listeners.filter((listener) => listener.once);
91+
const onListeners = listeners.filter((listener) => !listener.once);
92+
93+
// Initialize set to track executed once listeners
94+
const executedOnceListeners = new Set<ListenerFunction>();
95+
96+
// Create main handler for regular "on" listeners
97+
const mainHandler: ListenerFunction = async (...args) => {
98+
for (const listener of onListeners) {
99+
try {
100+
await listener.handler(...args);
101+
} catch (e) {
102+
Logger.error(
103+
`Error handling event ${name}${
104+
namespace ? ` of namespace ${namespace}` : ''
105+
}`,
106+
e,
107+
);
94108
}
95-
});
109+
}
110+
};
111+
112+
// Create handler for "once" listeners with cleanup logic
113+
const onceHandler: ListenerFunction = async (...args) => {
114+
for (const listener of onceListeners) {
115+
try {
116+
// Skip if already executed (shouldn't happen with proper .once registration, but just in case)
117+
if (executedOnceListeners.has(listener.handler)) continue;
118+
119+
await listener.handler(...args);
120+
executedOnceListeners.add(listener.handler);
121+
} catch (e) {
122+
Logger.error(
123+
`Error handling event ${name}${
124+
namespace ? ` of namespace ${namespace}` : ''
125+
}`,
126+
e,
127+
);
128+
}
129+
}
96130

97-
if (!mainListener) {
98-
this.loadedEvents.set(key, {
99-
...data,
100-
mainListener: main,
101-
});
102-
}
131+
// Cleanup: Remove once listeners that have been executed
132+
if (
133+
executedOnceListeners.size === onceListeners.length &&
134+
onListeners.length === 0
135+
) {
136+
// If all once listeners executed and no regular listeners, remove event entirely
137+
this.loadedEvents.delete(key);
138+
Logger.info(
139+
`🧹 Cleaned up completed once-only event ${name}${
140+
namespace ? ` of namespace ${namespace}` : ''
141+
}`,
142+
);
143+
}
144+
};
103145

104-
client.on(name, main);
146+
// Store main handlers in loadedEvents for later unregistration
147+
this.loadedEvents.set(key, {
148+
...data,
149+
mainListener:
150+
onListeners.length > 0
151+
? { handler: mainHandler, once: false }
152+
: undefined,
153+
executedOnceListeners,
154+
});
155+
156+
// Register handlers with appropriate methods
157+
if (namespace) {
158+
if (onListeners.length > 0) {
159+
this.commandkit.events.on(namespace, name, mainHandler);
160+
}
161+
if (onceListeners.length > 0) {
162+
this.commandkit.events.once(namespace, name, onceHandler);
163+
}
164+
} else {
165+
if (onListeners.length > 0) {
166+
client.on(name, mainHandler);
167+
}
168+
if (onceListeners.length > 0) {
169+
client.once(name, onceHandler);
170+
}
171+
}
105172

106173
Logger.info(
107174
`🔌 Registered event ${name}${
108175
namespace ? ` of namespace ${namespace}` : ''
109-
}`,
176+
} (${onListeners.length} regular, ${onceListeners.length} once-only)`,
110177
);
111178
}
112179
}
@@ -119,9 +186,17 @@ export class AppEventsHandler {
119186
{ name, mainListener, namespace },
120187
] of this.loadedEvents.entries()) {
121188
if (mainListener) {
122-
client.off(name, mainListener);
189+
if (namespace) {
190+
this.commandkit.events.off(namespace, name, mainListener.handler);
191+
} else {
192+
client.off(name, mainListener.handler);
193+
}
123194
} else {
124-
client.removeAllListeners(name);
195+
if (namespace) {
196+
this.commandkit.events.removeAllListeners(namespace, name);
197+
} else {
198+
client.removeAllListeners(name);
199+
}
125200
}
126201

127202
this.loadedEvents.delete(key);

packages/commandkit/src/app/i18n/DefaultLocalizationStrategy.ts

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import {
66
TranslationResult,
77
} from './LocalizationStrategy';
88
import { Translation } from './Translation';
9-
import { readFile } from 'node:fs/promises';
9+
import { readFile, writeFile } from 'node:fs/promises';
1010
import { join } from 'node:path';
11+
import { getConfig } from '../../config/config';
12+
import { existsSync } from 'node:fs';
1113

1214
export class DefaultLocalizationStrategy implements LocalizationStrategy {
1315
private translations: Map<string, Translation> = new Map();
@@ -24,12 +26,80 @@ export class DefaultLocalizationStrategy implements LocalizationStrategy {
2426
try {
2527
const data = await readFile(path, 'utf-8');
2628

27-
return JSON.parse(data) as Translation;
29+
const localeData = JSON.parse(data) as Translation;
30+
31+
await this.generateLocaleTypes(localeData);
32+
33+
return localeData;
2834
} catch {
2935
return null;
3036
}
3137
}
3238

39+
private async generateLocaleTypes(localeData: Translation) {
40+
const { typedLocales } = getConfig();
41+
if (!typedLocales) return;
42+
43+
const file = join(
44+
process.cwd(),
45+
'node_modules',
46+
'commandkit-types',
47+
'locale_types.d.ts',
48+
);
49+
50+
const header = `// auto generated file do not edit\ndeclare module 'commandkit' {\n export interface CommandLocalizationTypeData {\n`;
51+
const footer = ` }\n}`;
52+
53+
const generateType = (locale: Translation) => {
54+
return `"${locale.command}": ${JSON.stringify(
55+
Object.entries(locale.translations).map(([key, value]) => {
56+
// if value contains {xyz} then we need to parse it as an argument
57+
// so that it can be autocompleted
58+
const args = value.match(/{([^}]+)}/g);
59+
60+
if (!args) return `${JSON.stringify(key)}: null`;
61+
62+
return `${JSON.stringify(key)}: ${args.map((arg) => arg.slice(1, -1)).join(' | ')}`;
63+
}),
64+
null,
65+
2,
66+
)}`;
67+
};
68+
69+
if (!existsSync(file)) {
70+
const generated = generateType(localeData);
71+
72+
await writeFile(file, `${header}${generated}${footer}`);
73+
} else {
74+
const data = await readFile(file, 'utf-8');
75+
76+
const lines = data.split('\n');
77+
78+
const index = lines.findIndex((line) =>
79+
line.includes('CommandLocalizationTypeData'),
80+
);
81+
82+
if (index === -1) {
83+
for (const locale of this.translations.values()) {
84+
const generated = generateType(locale);
85+
86+
await writeFile(file, `${header}${generated}${footer}`);
87+
}
88+
return;
89+
}
90+
91+
const start = index + 2;
92+
93+
const end = lines.findIndex((line) => line.includes('}'));
94+
95+
const generated = generateType(localeData);
96+
97+
lines.splice(start, end - start, generated);
98+
99+
await writeFile(file, lines.join('\n'));
100+
}
101+
}
102+
33103
public async getTranslationStrict(scope: string, locale: Locale) {
34104
const key = `${scope}:${locale}`;
35105
if (!this.translations.has(key)) {

0 commit comments

Comments
 (0)