Skip to content

Commit 09ec621

Browse files
authored
fix(commandkit): pre-generate context menu commands before registration (#619)
* fix(commandkit): use node console constructor in logger * fix(commandkit): pre-generate context menu commands
1 parent cab2450 commit 09ec621

File tree

11 files changed

+475
-35
lines changed

11 files changed

+475
-35
lines changed

apps/website/docs/api-reference/commandkit/classes/app-command-handler.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
1313

1414
## AppCommandHandler
1515

16-
<GenerationInfo sourceFile="packages/commandkit/src/app/handlers/AppCommandHandler.ts" sourceLine="194" packageName="commandkit" />
16+
<GenerationInfo sourceFile="packages/commandkit/src/app/handlers/AppCommandHandler.ts" sourceLine="195" packageName="commandkit" />
1717

1818
Handles application commands for CommandKit, including loading, registration, and execution.
1919
Manages both slash commands and message commands with middleware support.
@@ -76,7 +76,7 @@ Prints a formatted banner showing all loaded commands organized by category.
7676

7777
<MemberInfo kind="method" type={`() => `} />
7878

79-
Gets an array of all loaded commands.
79+
Gets an array of all loaded commands, including pre-generated context menu entries.
8080
### registerCommandHandler
8181

8282
<MemberInfo kind="method" type={`() => `} />

apps/website/docs/api-reference/commandkit/classes/command-registrar.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ Creates an instance of CommandRegistrar.
4747

4848
<MemberInfo kind="method" type={`() => (<a href='/docs/api-reference/commandkit/types/command-data#commanddata'>CommandData</a> &#38; { __metadata?: <a href='/docs/api-reference/commandkit/interfaces/command-metadata#commandmetadata'>CommandMetadata</a>; __applyId(id: string): void; })[]`} />
4949

50-
Gets the commands data.
50+
Gets the commands data, consuming pre-generated context menu entries when available.
5151
### register
5252

5353
<MemberInfo kind="method" type={`() => `} />

apps/website/docs/api-reference/commandkit/classes/default-logger.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
1313

1414
## DefaultLogger
1515

16-
<GenerationInfo sourceFile="packages/commandkit/src/logger/DefaultLogger.ts" sourceLine="54" packageName="commandkit" />
16+
<GenerationInfo sourceFile="packages/commandkit/src/logger/DefaultLogger.ts" sourceLine="55" packageName="commandkit" />
1717

1818
Default logger implementation that logs messages to the console.
1919
It formats the log messages with timestamps, log levels, and context information.

apps/website/docs/api-reference/commandkit/interfaces/loaded-command.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
1313

1414
## LoadedCommand
1515

16-
<GenerationInfo sourceFile="packages/commandkit/src/app/handlers/AppCommandHandler.ts" sourceLine="91" packageName="commandkit" />
16+
<GenerationInfo sourceFile="packages/commandkit/src/app/handlers/AppCommandHandler.ts" sourceLine="92" packageName="commandkit" />
1717

1818
Represents a loaded command with its metadata and configuration.
1919

apps/website/docs/guide/02-commands/03-context-menu-commands.mdx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ understanding the nature of these context menu commands easier.
7878
</TabItem>
7979
</Tabs>
8080

81+
Once the command file is loaded, CommandKit also materializes the
82+
context menu variants in the command handler cache. This means
83+
introspection APIs such as `commandHandler.getCommandsArray()` and
84+
tools built on top of it can see the context menu entries as distinct
85+
commands immediately after load.
86+
8187
By just exporting the `messageContextMenu` function from your command
8288
file, CommandKit will properly update your command type before
8389
registering it to the Discord API. You don't have to manually set a
@@ -145,14 +151,19 @@ very similar to message context menu commands.
145151
</TabItem>
146152
</Tabs>
147153

148-
You may have noticed that the context menu command names are the same as the message command names.
149-
This is because the context menu command names are borrowed from the non-context menu command names.
154+
You may have noticed that the context menu command names are the same
155+
as the message command names. This is because the context menu command
156+
names are borrowed from the non-context menu command names.
150157

151-
In order to change the context menu command name, you can use the `nameAliases` option in the `CommandMetadata` object.
158+
In order to change the context menu command name, you can use the
159+
`nameAliases` option in the `CommandMetadata` object.
152160

153161
:::warning
154162

155-
If you are using the [i18n plugin](../05-official-plugins/05-commandkit-i18n.mdx), the plugin will add `name_localizations` to the `nameAliases` option if localization for the context menu command name is provided.
163+
If you are using the
164+
[i18n plugin](../05-official-plugins/05-commandkit-i18n.mdx), the
165+
plugin will add `name_localizations` to the `nameAliases` option if
166+
localization for the context menu command name is provided.
156167

157168
```json title="src/app/locales/en/command.json"
158169
{
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import { afterEach, describe, expect, test } from 'vitest';
2+
import { ApplicationCommandType, Client } from 'discord.js';
3+
import { join } from 'node:path';
4+
import { CommandKit } from '../src/commandkit';
5+
import {
6+
AppCommandHandler,
7+
type LoadedCommand,
8+
} from '../src/app/handlers/AppCommandHandler';
9+
import { CommandRegistrar } from '../src/app/register/CommandRegistrar';
10+
import type { Command } from '../src/app/router/CommandsRouter';
11+
import type { CommandMetadata } from '../src/types';
12+
13+
const fixturesDir = join(__dirname, 'fixtures');
14+
const noop = async () => {};
15+
const slowTestTimeout = 20_000;
16+
17+
function createCommandFixture(fileName: string, name: string): Command {
18+
return {
19+
id: crypto.randomUUID(),
20+
name,
21+
path: join(fixturesDir, fileName),
22+
relativePath: `\\${fileName}`,
23+
parentPath: fixturesDir,
24+
middlewares: [],
25+
category: null,
26+
};
27+
}
28+
29+
async function createHandler() {
30+
CommandKit.instance = undefined;
31+
32+
const client = new Client({ intents: [] });
33+
const commandkit = new CommandKit({ client });
34+
const handler = new AppCommandHandler(commandkit);
35+
36+
commandkit.commandHandler = handler;
37+
38+
return { client, commandkit, handler };
39+
}
40+
41+
async function loadFixtureCommand(
42+
handler: AppCommandHandler,
43+
fileName: string,
44+
name: string,
45+
) {
46+
const command = createCommandFixture(fileName, name);
47+
48+
await (handler as any).loadCommand(command.id, command);
49+
50+
return command;
51+
}
52+
53+
afterEach(async () => {
54+
CommandKit.instance = undefined;
55+
});
56+
57+
describe('Context menu registration', () => {
58+
test(
59+
'pre-generates context menu commands in the handler cache',
60+
async () => {
61+
const { client, handler } = await createHandler();
62+
63+
try {
64+
const command = await loadFixtureCommand(
65+
handler,
66+
'context-menu-command.mjs',
67+
'report',
68+
);
69+
const loadedCommands = handler.getCommandsArray();
70+
71+
expect(loadedCommands).toHaveLength(3);
72+
73+
const baseCommand = loadedCommands.find(
74+
(entry) => entry.command.id === command.id,
75+
);
76+
const userContextMenu = loadedCommands.find(
77+
(entry) => entry.command.id === `${command.id}::user-ctx`,
78+
);
79+
const messageContextMenu = loadedCommands.find(
80+
(entry) => entry.command.id === `${command.id}::message-ctx`,
81+
);
82+
83+
expect(baseCommand?.data.command.name).toBe('report');
84+
expect(baseCommand?.data.command.type).toBeUndefined();
85+
86+
expect(userContextMenu?.data.command.name).toBe('Report User');
87+
expect(userContextMenu?.data.command.type).toBe(
88+
ApplicationCommandType.User,
89+
);
90+
expect(userContextMenu?.data.command.description).toBeUndefined();
91+
expect(userContextMenu?.data.command.options).toBeUndefined();
92+
93+
expect(messageContextMenu?.data.command.name).toBe('Report Message');
94+
expect(messageContextMenu?.data.command.type).toBe(
95+
ApplicationCommandType.Message,
96+
);
97+
expect(messageContextMenu?.data.command.description).toBeUndefined();
98+
expect(messageContextMenu?.data.command.options).toBeUndefined();
99+
} finally {
100+
await client.destroy();
101+
}
102+
},
103+
slowTestTimeout,
104+
);
105+
106+
test(
107+
'keeps alias metadata resolvable after context menu pre-generation',
108+
async () => {
109+
const { client, handler } = await createHandler();
110+
111+
try {
112+
await loadFixtureCommand(handler, 'context-menu-command.mjs', 'report');
113+
114+
expect(handler.getMetadataFor('Report User', 'user')).toMatchObject({
115+
nameAliases: {
116+
user: 'Report User',
117+
message: 'Report Message',
118+
},
119+
});
120+
121+
expect(
122+
handler.getMetadataFor('Report Message', 'message'),
123+
).toMatchObject({
124+
nameAliases: {
125+
user: 'Report User',
126+
message: 'Report Message',
127+
},
128+
});
129+
} finally {
130+
await client.destroy();
131+
}
132+
},
133+
slowTestTimeout,
134+
);
135+
136+
test(
137+
'registers pre-generated context menu commands without duplication',
138+
async () => {
139+
const { client, handler } = await createHandler();
140+
141+
try {
142+
const command = await loadFixtureCommand(
143+
handler,
144+
'context-menu-command.mjs',
145+
'report',
146+
);
147+
const registrationCommands = handler.registrar.getCommandsData();
148+
149+
expect(registrationCommands).toHaveLength(3);
150+
151+
const slashCommand = registrationCommands.find(
152+
(entry) => entry.type === ApplicationCommandType.ChatInput,
153+
);
154+
const userContextMenu = registrationCommands.find(
155+
(entry) => entry.type === ApplicationCommandType.User,
156+
);
157+
const messageContextMenu = registrationCommands.find(
158+
(entry) => entry.type === ApplicationCommandType.Message,
159+
);
160+
161+
expect(slashCommand?.name).toBe('report');
162+
expect(userContextMenu?.name).toBe('Report User');
163+
expect(messageContextMenu?.name).toBe('Report Message');
164+
165+
expect(userContextMenu?.description).toBeUndefined();
166+
expect(userContextMenu?.options).toBeUndefined();
167+
expect(messageContextMenu?.description).toBeUndefined();
168+
expect(messageContextMenu?.options).toBeUndefined();
169+
170+
expect(userContextMenu?.__metadata?.nameAliases?.user).toBe(
171+
'Report User',
172+
);
173+
expect(messageContextMenu?.__metadata?.nameAliases?.message).toBe(
174+
'Report Message',
175+
);
176+
177+
slashCommand?.__applyId('slash-id');
178+
userContextMenu?.__applyId('user-id');
179+
messageContextMenu?.__applyId('message-id');
180+
181+
const loadedCommands = handler.getCommandsArray();
182+
183+
expect(
184+
loadedCommands.find((entry) => entry.command.id === command.id)
185+
?.discordId,
186+
).toBe('slash-id');
187+
expect(
188+
loadedCommands.find(
189+
(entry) => entry.command.id === `${command.id}::user-ctx`,
190+
)?.discordId,
191+
).toBe('user-id');
192+
expect(
193+
loadedCommands.find(
194+
(entry) => entry.command.id === `${command.id}::message-ctx`,
195+
)?.discordId,
196+
).toBe('message-id');
197+
} finally {
198+
await client.destroy();
199+
}
200+
},
201+
slowTestTimeout,
202+
);
203+
204+
test(
205+
'falls back to generating context menu payloads for external loaded commands',
206+
async () => {
207+
const { client, commandkit } = await createHandler();
208+
209+
try {
210+
const metadata: CommandMetadata = {
211+
nameAliases: {
212+
user: 'Inspect User',
213+
message: 'Inspect Message',
214+
},
215+
};
216+
217+
const externalCommand: LoadedCommand = {
218+
discordId: null,
219+
command: createCommandFixture('external-command.mjs', 'inspect'),
220+
metadata,
221+
data: {
222+
command: {
223+
name: 'inspect',
224+
description: 'Inspect content from a context menu command.',
225+
},
226+
metadata,
227+
userContextMenu: noop,
228+
messageContextMenu: noop,
229+
},
230+
};
231+
232+
commandkit.commandHandler = {
233+
getCommandsArray: () => [externalCommand],
234+
} as AppCommandHandler;
235+
236+
const registrationCommands = new CommandRegistrar(
237+
commandkit,
238+
).getCommandsData();
239+
240+
expect(registrationCommands).toHaveLength(2);
241+
expect(
242+
registrationCommands.every(
243+
(entry) => entry.type !== ApplicationCommandType.ChatInput,
244+
),
245+
).toBe(true);
246+
247+
const userContextMenu = registrationCommands.find(
248+
(entry) => entry.type === ApplicationCommandType.User,
249+
);
250+
const messageContextMenu = registrationCommands.find(
251+
(entry) => entry.type === ApplicationCommandType.Message,
252+
);
253+
254+
expect(userContextMenu?.name).toBe('Inspect User');
255+
expect(messageContextMenu?.name).toBe('Inspect Message');
256+
expect(userContextMenu?.description).toBeUndefined();
257+
expect(messageContextMenu?.description).toBeUndefined();
258+
} finally {
259+
await client.destroy();
260+
}
261+
},
262+
slowTestTimeout,
263+
);
264+
265+
test(
266+
'registers pre-generated context-menu-only commands without slash payloads',
267+
async () => {
268+
const { client, handler } = await createHandler();
269+
270+
try {
271+
await loadFixtureCommand(
272+
handler,
273+
'context-menu-only-command.mjs',
274+
'inspect',
275+
);
276+
277+
const registrationCommands = handler.registrar.getCommandsData();
278+
279+
expect(registrationCommands).toHaveLength(2);
280+
expect(
281+
registrationCommands.every(
282+
(entry) => entry.type !== ApplicationCommandType.ChatInput,
283+
),
284+
).toBe(true);
285+
expect(registrationCommands.map((entry) => entry.name).sort()).toEqual([
286+
'Inspect Message',
287+
'Inspect User',
288+
]);
289+
} finally {
290+
await client.destroy();
291+
}
292+
},
293+
slowTestTimeout,
294+
);
295+
});

0 commit comments

Comments
 (0)