@julanzw/ttoolbox-discordjs-framework 1.4.0 → 1.5.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.
@@ -1,4 +1,3 @@
1
- /* eslint-disable @typescript-eslint/no-unused-vars */
2
1
  import { SlashCommandBuilder, } from 'discord.js';
3
2
  import { safeReply } from '../utils/editAndReply.js';
4
3
  import { getPermissionsForLevel } from '../utils/permissions.js';
@@ -1,36 +1,105 @@
1
- import { ChatInputCommandInteraction, Client, RESTPostAPIChatInputApplicationCommandsJSONBody } from 'discord.js';
1
+ import { ChatInputCommandInteraction, Client, MessageContextMenuCommandInteraction, RESTPostAPIApplicationCommandsJSONBody, UserContextMenuCommandInteraction } from 'discord.js';
2
2
  import { ILogger } from '../types/logger.js';
3
3
  import { Command } from './Command.class.js';
4
4
  import { SubcommandGroup } from './SubcommandGroup.class.js';
5
5
  import { ErrorReporter } from '../utils/ErrorReporter.js';
6
+ import { MessageContextMenuCommand } from './MessageContextMenuCommand.class.js';
7
+ import { UserContextMenuCommand } from './UserContextMenuCommand.class.js';
8
+ import { LoadCommandsOptions } from '../types/loadCommands.js';
6
9
  export declare class CommandManager {
7
10
  private commands;
11
+ private userContextMenuCommands;
12
+ private messageContextMenuCommands;
8
13
  protected logger?: ILogger;
9
14
  protected errorReporter?: ErrorReporter;
10
15
  /**
16
+ * Register commands (accepts single, array, or mixed types)
17
+ * @param command - Command(s) to register
18
+ * @example
19
+ * ```typescript
20
+ * // Single command
21
+ * commandManager.registerCommand(new PingCommand());
22
+ *
23
+ * // Array of commands
24
+ * commandManager.registerCommand([cmd1, cmd2, cmd3]);
25
+ *
26
+ * // Mixed types
27
+ * commandManager.registerCommand([
28
+ * new PingCommand(),
29
+ * new UserContextMenu(),
30
+ * new SubcommandGroup(),
31
+ * ]);
32
+ * ```
33
+ */
34
+ registerCommand(command: Command | SubcommandGroup | UserContextMenuCommand | MessageContextMenuCommand | Array<Command | SubcommandGroup | UserContextMenuCommand | MessageContextMenuCommand | any>): void;
35
+ /**
36
+ * @experimental
37
+ * Automatically load and register all commands from a directory.
38
+ *
39
+ * This is a convenience wrapper around the loadCommands utility.
40
+ *
41
+ * @param dirPath - Absolute path to the directory containing commands
42
+ * @param options - Optional configuration
43
+ * @returns Number of commands successfully loaded
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * // Basic usage
48
+ * const count = await commandManager.loadFromDirectory('./src/commands');
49
+ * console.log(`Loaded ${count} commands`);
50
+ *
51
+ * // With options
52
+ * await commandManager.loadFromDirectory('./src/commands', {
53
+ * verbose: true,
54
+ * skipDirs: ['subcommands', 'test'],
55
+ * });
56
+ * ```
57
+ */
58
+ loadFromDirectory(dirPath: string, options?: LoadCommandsOptions): Promise<number>;
59
+ /**
60
+ * @deprecated Use `registerCommand()` instead
61
+ *
11
62
  * Register a single command or subcommand group
12
63
  */
13
64
  register(command: Command | SubcommandGroup): this;
14
65
  /**
66
+ * @deprecated Use `registerCommand()` with an array instead
67
+ *
15
68
  * Register multiple commands at once
16
69
  */
17
70
  registerMultiple(commands: Array<Command | SubcommandGroup>): this;
18
71
  /**
19
- * Get a specific command by name
72
+ * Get a specific slash command by name
20
73
  */
21
74
  get(name: string): Command | SubcommandGroup | undefined;
22
75
  /**
23
- * Get all registered commands
76
+ * Get a specific user context menu command by name
77
+ */
78
+ getUserContextMenu(name: string): UserContextMenuCommand | undefined;
79
+ /**
80
+ * Get a specific message context menu command by name
81
+ */
82
+ getMessageContextMenu(name: string): MessageContextMenuCommand | undefined;
83
+ /**
84
+ * Get all registered slash commands
24
85
  */
25
86
  getAll(): Array<Command | SubcommandGroup>;
26
87
  /**
27
- * Get all commands sorted alphabetically by name
88
+ * Get all registered user context menu commands
89
+ */
90
+ getAllUserContextMenus(): UserContextMenuCommand[];
91
+ /**
92
+ * Get all registered message context menu commands
93
+ */
94
+ getAllMessageContextMenus(): MessageContextMenuCommand[];
95
+ /**
96
+ * Get all slash commands sorted alphabetically by name
28
97
  */
29
98
  getAllSorted(): Array<Command | SubcommandGroup>;
30
99
  /**
31
100
  * Convert all commands to Discord JSON format for registration
32
101
  */
33
- toDiscordJSON(): RESTPostAPIChatInputApplicationCommandsJSONBody[];
102
+ toJSON(): RESTPostAPIApplicationCommandsJSONBody[];
34
103
  /**
35
104
  * Generate paginated help pages for display in help command
36
105
  * Returns a 2D array where each inner array is a page of command descriptions
@@ -48,25 +117,69 @@ export declare class CommandManager {
48
117
  */
49
118
  executeCommand(commandName: string, interaction: ChatInputCommandInteraction, client: Client): Promise<void>;
50
119
  /**
51
- * Get total number of registered commands
120
+ * Execute a user context menu command.
121
+ */
122
+ executeUserContextMenu(commandName: string, interaction: UserContextMenuCommandInteraction, client: Client): Promise<void>;
123
+ /**
124
+ * Execute a message context menu command.
125
+ */
126
+ executeMessageContextMenu(commandName: string, interaction: MessageContextMenuCommandInteraction, client: Client): Promise<void>;
127
+ /**
128
+ * Get total number of registered slash commands
52
129
  */
53
130
  get size(): number;
54
131
  /**
55
- * Check if a command exists
132
+ * Get total number of all registered commands
133
+ */
134
+ get totalSize(): number;
135
+ /**
136
+ * Check if a slash command exists
56
137
  */
57
138
  has(name: string): boolean;
58
139
  /**
59
- * Remove a command (useful for hot-reloading in dev)
140
+ * Check if a user context menu command exists
141
+ */
142
+ hasUserContextMenu(name: string): boolean;
143
+ /**
144
+ * Check if a message context menu command exists
145
+ */
146
+ hasMessageContextMenu(name: string): boolean;
147
+ /**
148
+ * Check if any command (slash, user context, or message context) exists
149
+ */
150
+ hasAny(name: string): boolean;
151
+ /**
152
+ * Remove a slash command (useful for hot-reloading in dev)
60
153
  */
61
154
  unregister(name: string): boolean;
62
155
  /**
63
- * Clear all commands
156
+ * Remove a user context menu command
157
+ */
158
+ unregisterUserContextMenu(name: string): boolean;
159
+ /**
160
+ * Remove a message context menu command
161
+ */
162
+ unregisterMessageContextMenu(name: string): boolean;
163
+ /**
164
+ * Clear all commands (slash commands, user context menus, and message context menus)
64
165
  */
65
166
  clear(): void;
66
167
  /**
67
- * Get command names as an array
168
+ * Get slash command names as an array
68
169
  */
69
170
  getCommandNames(): string[];
171
+ /**
172
+ * Get user context menu command names as an array
173
+ */
174
+ getUserContextMenuNames(): string[];
175
+ /**
176
+ * Get message context menu command names as an array
177
+ */
178
+ getMessageContextMenuNames(): string[];
179
+ /**
180
+ * Get all command names (slash + context menus) as an array
181
+ */
182
+ getAllCommandNames(): string[];
70
183
  setLogger(logger: ILogger): this;
71
184
  /**
72
185
  * Set the error reporter for all commands
@@ -1,42 +1,148 @@
1
+ import { Command } from './Command.class.js';
1
2
  import { SubcommandGroup } from './SubcommandGroup.class.js';
3
+ import { MessageContextMenuCommand } from './MessageContextMenuCommand.class.js';
4
+ import { UserContextMenuCommand } from './UserContextMenuCommand.class.js';
5
+ import { loadCommands } from '../utils/loadCommands.js';
2
6
  export class CommandManager {
3
7
  constructor() {
4
8
  this.commands = new Map();
9
+ this.userContextMenuCommands = new Map();
10
+ this.messageContextMenuCommands = new Map();
5
11
  }
6
12
  /**
7
- * Register a single command or subcommand group
13
+ * Register commands (accepts single, array, or mixed types)
14
+ * @param command - Command(s) to register
15
+ * @example
16
+ * ```typescript
17
+ * // Single command
18
+ * commandManager.registerCommand(new PingCommand());
19
+ *
20
+ * // Array of commands
21
+ * commandManager.registerCommand([cmd1, cmd2, cmd3]);
22
+ *
23
+ * // Mixed types
24
+ * commandManager.registerCommand([
25
+ * new PingCommand(),
26
+ * new UserContextMenu(),
27
+ * new SubcommandGroup(),
28
+ * ]);
29
+ * ```
8
30
  */
9
- register(command) {
10
- if (this.logger) {
31
+ registerCommand(command) {
32
+ if (Array.isArray(command)) {
33
+ for (const cmd of command) {
34
+ this.registerCommand(cmd);
35
+ }
36
+ return;
37
+ }
38
+ if (this.logger && 'setLogger' in command) {
11
39
  command.setLogger(this.logger);
12
40
  }
13
- if (this.errorReporter) {
41
+ if (this.errorReporter && 'setErrorReporter' in command) {
14
42
  command.setErrorReporter(this.errorReporter);
15
43
  }
16
- this.commands.set(command.name, command);
44
+ if (command instanceof Command || command instanceof SubcommandGroup) {
45
+ this.commands.set(command.name, command);
46
+ }
47
+ else if (command instanceof UserContextMenuCommand) {
48
+ this.userContextMenuCommands.set(command.name, command);
49
+ }
50
+ else if (command instanceof MessageContextMenuCommand) {
51
+ this.messageContextMenuCommands.set(command.name, command);
52
+ }
53
+ else {
54
+ this.logger?.warn(`Attempted to register invalid command type: ${command?.constructor?.name || 'unknown'}`, 'command-manager');
55
+ }
56
+ }
57
+ /**
58
+ * @experimental
59
+ * Automatically load and register all commands from a directory.
60
+ *
61
+ * This is a convenience wrapper around the loadCommands utility.
62
+ *
63
+ * @param dirPath - Absolute path to the directory containing commands
64
+ * @param options - Optional configuration
65
+ * @returns Number of commands successfully loaded
66
+ *
67
+ * @example
68
+ * ```typescript
69
+ * // Basic usage
70
+ * const count = await commandManager.loadFromDirectory('./src/commands');
71
+ * console.log(`Loaded ${count} commands`);
72
+ *
73
+ * // With options
74
+ * await commandManager.loadFromDirectory('./src/commands', {
75
+ * verbose: true,
76
+ * skipDirs: ['subcommands', 'test'],
77
+ * });
78
+ * ```
79
+ */
80
+ async loadFromDirectory(dirPath, options = {}) {
81
+ const commands = await loadCommands(dirPath, options);
82
+ this.registerCommand(commands);
83
+ const validCount = commands.filter(cmd => cmd instanceof Command ||
84
+ cmd instanceof SubcommandGroup ||
85
+ cmd instanceof UserContextMenuCommand ||
86
+ cmd instanceof MessageContextMenuCommand).length;
87
+ this.logger?.info(`Auto-loaded ${validCount} commands from ${dirPath}`, 'command-manager');
88
+ return validCount;
89
+ }
90
+ /**
91
+ * @deprecated Use `registerCommand()` instead
92
+ *
93
+ * Register a single command or subcommand group
94
+ */
95
+ register(command) {
96
+ this.registerCommand(command);
17
97
  return this;
18
98
  }
19
99
  /**
100
+ * @deprecated Use `registerCommand()` with an array instead
101
+ *
20
102
  * Register multiple commands at once
21
103
  */
22
104
  registerMultiple(commands) {
23
- commands.forEach((cmd) => this.register(cmd));
105
+ this.registerCommand(commands);
24
106
  return this;
25
107
  }
26
108
  /**
27
- * Get a specific command by name
109
+ * Get a specific slash command by name
28
110
  */
29
111
  get(name) {
30
112
  return this.commands.get(name);
31
113
  }
32
114
  /**
33
- * Get all registered commands
115
+ * Get a specific user context menu command by name
116
+ */
117
+ getUserContextMenu(name) {
118
+ return this.userContextMenuCommands.get(name);
119
+ }
120
+ /**
121
+ * Get a specific message context menu command by name
122
+ */
123
+ getMessageContextMenu(name) {
124
+ return this.messageContextMenuCommands.get(name);
125
+ }
126
+ /**
127
+ * Get all registered slash commands
34
128
  */
35
129
  getAll() {
36
130
  return Array.from(this.commands.values());
37
131
  }
38
132
  /**
39
- * Get all commands sorted alphabetically by name
133
+ * Get all registered user context menu commands
134
+ */
135
+ getAllUserContextMenus() {
136
+ return Array.from(this.userContextMenuCommands.values());
137
+ }
138
+ /**
139
+ * Get all registered message context menu commands
140
+ */
141
+ getAllMessageContextMenus() {
142
+ return Array.from(this.messageContextMenuCommands.values());
143
+ }
144
+ /**
145
+ * Get all slash commands sorted alphabetically by name
40
146
  */
41
147
  getAllSorted() {
42
148
  return this.getAll().sort((a, b) => a.name.localeCompare(b.name));
@@ -44,13 +150,26 @@ export class CommandManager {
44
150
  /**
45
151
  * Convert all commands to Discord JSON format for registration
46
152
  */
47
- toDiscordJSON() {
48
- return this.getAll().map((cmd) => {
153
+ toJSON() {
154
+ const slashCommands = Array.from(this.commands.values()).map((cmd) => {
49
155
  if (process.env.ENV === 'dev') {
50
156
  console.log(`Registering: ${cmd.name}`);
51
157
  }
52
158
  return cmd.toJSON();
53
159
  });
160
+ const userContextMenus = Array.from(this.userContextMenuCommands.values()).map((cmd) => {
161
+ if (process.env.ENV === 'dev') {
162
+ console.log(`Registering user context menu: ${cmd.name}`);
163
+ }
164
+ return cmd.toJSON();
165
+ });
166
+ const messageContextMenus = Array.from(this.messageContextMenuCommands.values()).map((cmd) => {
167
+ if (process.env.ENV === 'dev') {
168
+ console.log(`Registering message context menu: ${cmd.name}`);
169
+ }
170
+ return cmd.toJSON();
171
+ });
172
+ return [...slashCommands, ...userContextMenus, ...messageContextMenus];
54
173
  }
55
174
  /**
56
175
  * Generate paginated help pages for display in help command
@@ -112,41 +231,131 @@ export class CommandManager {
112
231
  await command.execute(interaction, client);
113
232
  }
114
233
  /**
115
- * Get total number of registered commands
234
+ * Execute a user context menu command.
235
+ */
236
+ async executeUserContextMenu(commandName, interaction, client) {
237
+ const command = this.userContextMenuCommands.get(commandName);
238
+ if (!command) {
239
+ throw new Error(`User context menu command not found: ${commandName}`);
240
+ }
241
+ await command.execute(interaction, client);
242
+ }
243
+ /**
244
+ * Execute a message context menu command.
245
+ */
246
+ async executeMessageContextMenu(commandName, interaction, client) {
247
+ const command = this.messageContextMenuCommands.get(commandName);
248
+ if (!command) {
249
+ throw new Error(`Message context menu command not found: ${commandName}`);
250
+ }
251
+ await command.execute(interaction, client);
252
+ }
253
+ /**
254
+ * Get total number of registered slash commands
116
255
  */
117
256
  get size() {
118
257
  return this.commands.size;
119
258
  }
120
259
  /**
121
- * Check if a command exists
260
+ * Get total number of all registered commands
261
+ */
262
+ get totalSize() {
263
+ return this.commands.size +
264
+ this.userContextMenuCommands.size +
265
+ this.messageContextMenuCommands.size;
266
+ }
267
+ /**
268
+ * Check if a slash command exists
122
269
  */
123
270
  has(name) {
124
271
  return this.commands.has(name);
125
272
  }
126
273
  /**
127
- * Remove a command (useful for hot-reloading in dev)
274
+ * Check if a user context menu command exists
275
+ */
276
+ hasUserContextMenu(name) {
277
+ return this.userContextMenuCommands.has(name);
278
+ }
279
+ /**
280
+ * Check if a message context menu command exists
281
+ */
282
+ hasMessageContextMenu(name) {
283
+ return this.messageContextMenuCommands.has(name);
284
+ }
285
+ /**
286
+ * Check if any command (slash, user context, or message context) exists
287
+ */
288
+ hasAny(name) {
289
+ return this.commands.has(name) ||
290
+ this.userContextMenuCommands.has(name) ||
291
+ this.messageContextMenuCommands.has(name);
292
+ }
293
+ /**
294
+ * Remove a slash command (useful for hot-reloading in dev)
128
295
  */
129
296
  unregister(name) {
130
297
  return this.commands.delete(name);
131
298
  }
132
299
  /**
133
- * Clear all commands
300
+ * Remove a user context menu command
301
+ */
302
+ unregisterUserContextMenu(name) {
303
+ return this.userContextMenuCommands.delete(name);
304
+ }
305
+ /**
306
+ * Remove a message context menu command
307
+ */
308
+ unregisterMessageContextMenu(name) {
309
+ return this.messageContextMenuCommands.delete(name);
310
+ }
311
+ /**
312
+ * Clear all commands (slash commands, user context menus, and message context menus)
134
313
  */
135
314
  clear() {
136
315
  this.commands.clear();
316
+ this.userContextMenuCommands.clear();
317
+ this.messageContextMenuCommands.clear();
137
318
  }
138
319
  /**
139
- * Get command names as an array
320
+ * Get slash command names as an array
140
321
  */
141
322
  getCommandNames() {
142
323
  return Array.from(this.commands.keys());
143
324
  }
325
+ /**
326
+ * Get user context menu command names as an array
327
+ */
328
+ getUserContextMenuNames() {
329
+ return Array.from(this.userContextMenuCommands.keys());
330
+ }
331
+ /**
332
+ * Get message context menu command names as an array
333
+ */
334
+ getMessageContextMenuNames() {
335
+ return Array.from(this.messageContextMenuCommands.keys());
336
+ }
337
+ /**
338
+ * Get all command names (slash + context menus) as an array
339
+ */
340
+ getAllCommandNames() {
341
+ return [
342
+ ...this.getCommandNames(),
343
+ ...this.getUserContextMenuNames(),
344
+ ...this.getMessageContextMenuNames(),
345
+ ];
346
+ }
144
347
  setLogger(logger) {
145
348
  this.logger = logger;
146
349
  // Inject logger into all already-registered commands
147
350
  for (const command of this.commands.values()) {
148
351
  command.setLogger(logger);
149
352
  }
353
+ for (const command of this.userContextMenuCommands.values()) {
354
+ command.setLogger(logger);
355
+ }
356
+ for (const command of this.messageContextMenuCommands.values()) {
357
+ command.setLogger(logger);
358
+ }
150
359
  return this;
151
360
  }
152
361
  /**
@@ -157,6 +366,12 @@ export class CommandManager {
157
366
  for (const command of this.commands.values()) {
158
367
  command.setErrorReporter(reporter);
159
368
  }
369
+ for (const command of this.userContextMenuCommands.values()) {
370
+ command.setErrorReporter(reporter);
371
+ }
372
+ for (const command of this.messageContextMenuCommands.values()) {
373
+ command.setErrorReporter(reporter);
374
+ }
160
375
  return this;
161
376
  }
162
377
  }
@@ -0,0 +1,79 @@
1
+ import { MessageContextMenuCommandInteraction, Client } from 'discord.js';
2
+ import type { ILogger } from '../types/logger.js';
3
+ import type { ErrorReporter } from '../utils/ErrorReporter.js';
4
+ import { PermissionLevel } from '../types/permission.js';
5
+ /**
6
+ * Base class for Message Context Menu Commands.
7
+ *
8
+ * Message context menu commands appear when right-clicking on a message and
9
+ * selecting "Apps" in the context menu.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * export class ReportMessageCommand extends MessageContextMenuCommand {
14
+ * name = 'Report Message';
15
+ * guildOnly = true;
16
+ * permissionLevel = 'user' as const;
17
+ *
18
+ * protected async run(interaction: MessageContextMenuCommandInteraction) {
19
+ * const message = interaction.targetMessage;
20
+ * await reportMessage(message);
21
+ * await interaction.reply({ content: 'Message reported!', ephemeral: true });
22
+ * }
23
+ * }
24
+ * ```
25
+ */
26
+ export declare abstract class MessageContextMenuCommand {
27
+ abstract name: string;
28
+ abstract guildOnly: boolean;
29
+ abstract permissionLevel: PermissionLevel;
30
+ protected logger?: ILogger;
31
+ protected errorReporter?: ErrorReporter;
32
+ /**
33
+ * The main execution method - implement your command logic here.
34
+ */
35
+ protected abstract run(interaction: MessageContextMenuCommandInteraction, client: Client): Promise<void>;
36
+ /**
37
+ * Optional: Called before command execution.
38
+ * Return false to stop execution.
39
+ */
40
+ protected beforeExecute?(interaction: MessageContextMenuCommandInteraction, client: Client): Promise<boolean | void>;
41
+ /**
42
+ * Optional: Called after successful command execution.
43
+ */
44
+ protected afterExecute?(interaction: MessageContextMenuCommandInteraction, client: Client): Promise<void>;
45
+ /**
46
+ * Optional: Called when command execution fails.
47
+ */
48
+ protected onError?(interaction: MessageContextMenuCommandInteraction, error: Error, client: Client): Promise<void>;
49
+ /**
50
+ * Set the logger for this command.
51
+ */
52
+ setLogger(logger: ILogger): void;
53
+ /**
54
+ * Set the error reporter for this command.
55
+ */
56
+ setErrorReporter(reporter: ErrorReporter): void;
57
+ /**
58
+ * Log a message using the configured logger.
59
+ */
60
+ protected log(message: string, level: string, scope: string, logToConsole?: boolean): void;
61
+ /**
62
+ * Validate the interaction (guild-only check).
63
+ */
64
+ protected validate(interaction: MessageContextMenuCommandInteraction): string | null;
65
+ /**
66
+ * Check if the user has permission to use this command.
67
+ * Override this method to implement custom permission logic.
68
+ */
69
+ protected hasPermission(interaction: MessageContextMenuCommandInteraction): Promise<boolean>;
70
+ /**
71
+ * Execute the command with validation and error handling.
72
+ */
73
+ execute(interaction: MessageContextMenuCommandInteraction, client: Client): Promise<void>;
74
+ private safeExecute;
75
+ /**
76
+ * Convert this command to Discord API JSON format.
77
+ */
78
+ toJSON(): import("discord.js").RESTPostAPIContextMenuApplicationCommandsJSONBody;
79
+ }
@@ -0,0 +1,120 @@
1
+ import { ContextMenuCommandBuilder, ApplicationCommandType, } from 'discord.js';
2
+ import { safeReply } from '../utils/editAndReply.js';
3
+ /**
4
+ * Base class for Message Context Menu Commands.
5
+ *
6
+ * Message context menu commands appear when right-clicking on a message and
7
+ * selecting "Apps" in the context menu.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * export class ReportMessageCommand extends MessageContextMenuCommand {
12
+ * name = 'Report Message';
13
+ * guildOnly = true;
14
+ * permissionLevel = 'user' as const;
15
+ *
16
+ * protected async run(interaction: MessageContextMenuCommandInteraction) {
17
+ * const message = interaction.targetMessage;
18
+ * await reportMessage(message);
19
+ * await interaction.reply({ content: 'Message reported!', ephemeral: true });
20
+ * }
21
+ * }
22
+ * ```
23
+ */
24
+ export class MessageContextMenuCommand {
25
+ /**
26
+ * Set the logger for this command.
27
+ */
28
+ setLogger(logger) {
29
+ this.logger = logger;
30
+ }
31
+ /**
32
+ * Set the error reporter for this command.
33
+ */
34
+ setErrorReporter(reporter) {
35
+ this.errorReporter = reporter;
36
+ }
37
+ /**
38
+ * Log a message using the configured logger.
39
+ */
40
+ log(message, level, scope, logToConsole = false) {
41
+ this.logger?.log(message, level, scope, logToConsole);
42
+ }
43
+ /**
44
+ * Validate the interaction (guild-only check).
45
+ */
46
+ validate(interaction) {
47
+ if (this.guildOnly && !interaction.guildId) {
48
+ return 'This command can only be used in a server.';
49
+ }
50
+ return null;
51
+ }
52
+ /**
53
+ * Check if the user has permission to use this command.
54
+ * Override this method to implement custom permission logic.
55
+ */
56
+ async hasPermission(interaction) {
57
+ // Default: everyone has permission
58
+ return true;
59
+ }
60
+ /**
61
+ * Execute the command with validation and error handling.
62
+ */
63
+ async execute(interaction, client) {
64
+ await this.safeExecute(this.name, interaction, client, async () => {
65
+ const error = this.validate(interaction);
66
+ if (error)
67
+ return await safeReply(interaction, error, true);
68
+ if (!(await this.hasPermission(interaction))) {
69
+ return await safeReply(interaction, 'You do not have permission to use this command.', true);
70
+ }
71
+ if (this.beforeExecute) {
72
+ const shouldContinue = await this.beforeExecute(interaction, client);
73
+ if (shouldContinue === false)
74
+ return;
75
+ }
76
+ try {
77
+ await this.run(interaction, client);
78
+ if (this.afterExecute) {
79
+ await this.afterExecute(interaction, client);
80
+ }
81
+ }
82
+ catch (err) {
83
+ if (this.onError) {
84
+ await this.onError(interaction, err, client);
85
+ }
86
+ throw err;
87
+ }
88
+ });
89
+ }
90
+ async safeExecute(commandName, interaction, client, fn) {
91
+ const scope = `${commandName}_EXECUTION`;
92
+ try {
93
+ await fn();
94
+ this.log(`${commandName} context menu command executed`, 'info', scope);
95
+ }
96
+ catch (err) {
97
+ this.log('An Error occurred: ' + err, 'error', scope, true);
98
+ if (this.errorReporter) {
99
+ await this.errorReporter.reportError(err, `Context Menu: ${commandName}`, {
100
+ user: interaction.user.tag,
101
+ userId: interaction.user.id,
102
+ messageAuthor: interaction.targetMessage.author.tag,
103
+ messageContent: interaction.targetMessage.content.slice(0, 100),
104
+ guild: interaction.guild?.name,
105
+ guildId: interaction.guildId,
106
+ });
107
+ }
108
+ return await safeReply(interaction, 'An unexpected error occurred.');
109
+ }
110
+ }
111
+ /**
112
+ * Convert this command to Discord API JSON format.
113
+ */
114
+ toJSON() {
115
+ return new ContextMenuCommandBuilder()
116
+ .setName(this.name)
117
+ .setType(ApplicationCommandType.Message)
118
+ .toJSON();
119
+ }
120
+ }
@@ -0,0 +1,78 @@
1
+ import { UserContextMenuCommandInteraction, Client } from 'discord.js';
2
+ import type { ILogger } from '../types/logger.js';
3
+ import type { ErrorReporter } from '../utils/ErrorReporter.js';
4
+ import { PermissionLevel } from '../types/permission.js';
5
+ /**
6
+ * Base class for User Context Menu Commands.
7
+ *
8
+ * User context menu commands appear when right-clicking on a user and
9
+ * selecting "Apps" in the context menu.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * export class GetUserInfoCommand extends UserContextMenuCommand {
14
+ * name = 'Get User Info';
15
+ * guildOnly = false;
16
+ * permissionLevel = 'user' as const;
17
+ *
18
+ * protected async run(interaction: UserContextMenuCommandInteraction) {
19
+ * const user = interaction.targetUser;
20
+ * await interaction.reply(`User: ${user.tag}\nID: ${user.id}`);
21
+ * }
22
+ * }
23
+ * ```
24
+ */
25
+ export declare abstract class UserContextMenuCommand {
26
+ abstract name: string;
27
+ abstract guildOnly: boolean;
28
+ abstract permissionLevel: PermissionLevel;
29
+ protected logger?: ILogger;
30
+ protected errorReporter?: ErrorReporter;
31
+ /**
32
+ * The main execution method - implement your command logic here.
33
+ */
34
+ protected abstract run(interaction: UserContextMenuCommandInteraction, client: Client): Promise<void>;
35
+ /**
36
+ * Optional: Called before command execution.
37
+ * Return false to stop execution.
38
+ */
39
+ protected beforeExecute?(interaction: UserContextMenuCommandInteraction, client: Client): Promise<boolean | void>;
40
+ /**
41
+ * Optional: Called after successful command execution.
42
+ */
43
+ protected afterExecute?(interaction: UserContextMenuCommandInteraction, client: Client): Promise<void>;
44
+ /**
45
+ * Optional: Called when command execution fails.
46
+ */
47
+ protected onError?(interaction: UserContextMenuCommandInteraction, error: Error, client: Client): Promise<void>;
48
+ /**
49
+ * Set the logger for this command.
50
+ */
51
+ setLogger(logger: ILogger): void;
52
+ /**
53
+ * Set the error reporter for this command.
54
+ */
55
+ setErrorReporter(reporter: ErrorReporter): void;
56
+ /**
57
+ * Log a message using the configured logger.
58
+ */
59
+ protected log(message: string, level: string, scope: string, logToConsole?: boolean): void;
60
+ /**
61
+ * Validate the interaction (guild-only check).
62
+ */
63
+ protected validate(interaction: UserContextMenuCommandInteraction): string | null;
64
+ /**
65
+ * Check if the user has permission to use this command.
66
+ * Override this method to implement custom permission logic.
67
+ */
68
+ protected hasPermission(interaction: UserContextMenuCommandInteraction): Promise<boolean>;
69
+ /**
70
+ * Execute the command with validation and error handling.
71
+ */
72
+ execute(interaction: UserContextMenuCommandInteraction, client: Client): Promise<void>;
73
+ private safeExecute;
74
+ /**
75
+ * Convert this command to Discord API JSON format.
76
+ */
77
+ toJSON(): import("discord.js").RESTPostAPIContextMenuApplicationCommandsJSONBody;
78
+ }
@@ -0,0 +1,119 @@
1
+ import { ContextMenuCommandBuilder, ApplicationCommandType, } from 'discord.js';
2
+ import { safeReply } from '../utils/editAndReply.js';
3
+ /**
4
+ * Base class for User Context Menu Commands.
5
+ *
6
+ * User context menu commands appear when right-clicking on a user and
7
+ * selecting "Apps" in the context menu.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * export class GetUserInfoCommand extends UserContextMenuCommand {
12
+ * name = 'Get User Info';
13
+ * guildOnly = false;
14
+ * permissionLevel = 'user' as const;
15
+ *
16
+ * protected async run(interaction: UserContextMenuCommandInteraction) {
17
+ * const user = interaction.targetUser;
18
+ * await interaction.reply(`User: ${user.tag}\nID: ${user.id}`);
19
+ * }
20
+ * }
21
+ * ```
22
+ */
23
+ export class UserContextMenuCommand {
24
+ /**
25
+ * Set the logger for this command.
26
+ */
27
+ setLogger(logger) {
28
+ this.logger = logger;
29
+ }
30
+ /**
31
+ * Set the error reporter for this command.
32
+ */
33
+ setErrorReporter(reporter) {
34
+ this.errorReporter = reporter;
35
+ }
36
+ /**
37
+ * Log a message using the configured logger.
38
+ */
39
+ log(message, level, scope, logToConsole = false) {
40
+ this.logger?.log(message, level, scope, logToConsole);
41
+ }
42
+ /**
43
+ * Validate the interaction (guild-only check).
44
+ */
45
+ validate(interaction) {
46
+ if (this.guildOnly && !interaction.guildId) {
47
+ return 'This command can only be used in a server.';
48
+ }
49
+ return null;
50
+ }
51
+ /**
52
+ * Check if the user has permission to use this command.
53
+ * Override this method to implement custom permission logic.
54
+ */
55
+ async hasPermission(interaction) {
56
+ // Default: everyone has permission
57
+ return true;
58
+ }
59
+ /**
60
+ * Execute the command with validation and error handling.
61
+ */
62
+ async execute(interaction, client) {
63
+ await this.safeExecute(this.name, interaction, client, async () => {
64
+ const error = this.validate(interaction);
65
+ if (error)
66
+ return await safeReply(interaction, error, true);
67
+ if (!(await this.hasPermission(interaction))) {
68
+ return await safeReply(interaction, 'You do not have permission to use this command.', true);
69
+ }
70
+ if (this.beforeExecute) {
71
+ const shouldContinue = await this.beforeExecute(interaction, client);
72
+ if (shouldContinue === false)
73
+ return;
74
+ }
75
+ try {
76
+ await this.run(interaction, client);
77
+ if (this.afterExecute) {
78
+ await this.afterExecute(interaction, client);
79
+ }
80
+ }
81
+ catch (err) {
82
+ if (this.onError) {
83
+ await this.onError(interaction, err, client);
84
+ }
85
+ throw err;
86
+ }
87
+ });
88
+ }
89
+ async safeExecute(commandName, interaction, client, fn) {
90
+ const scope = `${commandName}_EXECUTION`;
91
+ try {
92
+ await fn();
93
+ this.log(`${commandName} context menu command executed`, 'info', scope);
94
+ }
95
+ catch (err) {
96
+ this.log('An Error occurred: ' + err, 'error', scope, true);
97
+ if (this.errorReporter) {
98
+ await this.errorReporter.reportError(err, `Context Menu: ${commandName}`, {
99
+ user: interaction.user.tag,
100
+ userId: interaction.user.id,
101
+ targetUser: interaction.targetUser.tag,
102
+ targetUserId: interaction.targetUser.id,
103
+ guild: interaction.guild?.name,
104
+ guildId: interaction.guildId,
105
+ });
106
+ }
107
+ return await safeReply(interaction, 'An unexpected error occurred.');
108
+ }
109
+ }
110
+ /**
111
+ * Convert this command to Discord API JSON format.
112
+ */
113
+ toJSON() {
114
+ return new ContextMenuCommandBuilder()
115
+ .setName(this.name)
116
+ .setType(ApplicationCommandType.User)
117
+ .toJSON();
118
+ }
119
+ }
package/dist/index.d.ts CHANGED
@@ -3,21 +3,25 @@ export { SubcommandGroup } from './classes/SubcommandGroup.class.js';
3
3
  export { CommandManager } from './classes/CommandManager.class.js';
4
4
  export { DiscordHandler } from './classes/DiscordHandler.class.js';
5
5
  export { ModalManager } from './classes/ModalManager.class.js';
6
- export { PaginatedEmbed } from './utils/PaginatedEmbed.class.js';
6
+ export { UserContextMenuCommand } from './classes/UserContextMenuCommand.class.js';
7
+ export { MessageContextMenuCommand } from './classes/MessageContextMenuCommand.class.js';
7
8
  export type { PermissionLevel } from './types/permission.js';
8
9
  export type { Modal, ModalField } from './types/modal.js';
9
10
  export type { ButtonType } from './types/button.js';
10
11
  export type { ILogger } from './types/logger.js';
11
12
  export type { AnySelectMenuInteraction, ButtonHandler, SelectMenuHandler, ComponentConfig } from './types/component.js';
12
13
  export type { AutocompleteHandler } from './types/autocomplete.js';
14
+ export type { LoadCommandsOptions } from './types/loadCommands.js';
13
15
  export { getPermissionsForLevel } from './utils/permissions.js';
14
16
  export { embedBuilder, createButton, createButtonsRow, createPaginationButtons, } from './utils/embeds.js';
15
17
  export { stringOption, integerOption, booleanOption, userOption, channelOption, roleOption, } from './utils/slashCommandOptions.js';
16
18
  export { safeReply, safeEdit } from './utils/editAndReply.js';
17
19
  export { formatDuration, formatDateToString, formatDateToYYYYMMDDHHMMSS, formatDateToDDMMYYYY, getDaySuffix, capitalizeFirst, } from './utils/formatting.js';
20
+ export { loadCommands } from './utils/loadCommands.js';
18
21
  export { TIMES_MILISECONDS } from './utils/miliseconds.js';
19
22
  export { TToolboxLogger } from './utils/TToolboxLogger.class.js';
20
23
  export { ErrorReporter } from './utils/ErrorReporter.js';
21
24
  export { ComponentManager } from './utils/ComponentManager.class.js';
22
25
  export { AutocompleteManager } from './utils/AutocompleteManager.class.js';
26
+ export { PaginatedEmbed } from './utils/PaginatedEmbed.class.js';
23
27
  export { InteractionError } from './classes/InteractionError.class.js';
package/dist/index.js CHANGED
@@ -4,17 +4,20 @@ export { SubcommandGroup } from './classes/SubcommandGroup.class.js';
4
4
  export { CommandManager } from './classes/CommandManager.class.js';
5
5
  export { DiscordHandler } from './classes/DiscordHandler.class.js';
6
6
  export { ModalManager } from './classes/ModalManager.class.js';
7
- export { PaginatedEmbed } from './utils/PaginatedEmbed.class.js';
7
+ export { UserContextMenuCommand } from './classes/UserContextMenuCommand.class.js';
8
+ export { MessageContextMenuCommand } from './classes/MessageContextMenuCommand.class.js';
8
9
  // Utilities
9
10
  export { getPermissionsForLevel } from './utils/permissions.js';
10
11
  export { embedBuilder, createButton, createButtonsRow, createPaginationButtons, } from './utils/embeds.js';
11
12
  export { stringOption, integerOption, booleanOption, userOption, channelOption, roleOption, } from './utils/slashCommandOptions.js';
12
13
  export { safeReply, safeEdit } from './utils/editAndReply.js';
13
14
  export { formatDuration, formatDateToString, formatDateToYYYYMMDDHHMMSS, formatDateToDDMMYYYY, getDaySuffix, capitalizeFirst, } from './utils/formatting.js';
15
+ export { loadCommands } from './utils/loadCommands.js';
14
16
  export { TIMES_MILISECONDS } from './utils/miliseconds.js';
15
17
  export { TToolboxLogger } from './utils/TToolboxLogger.class.js';
16
18
  export { ErrorReporter } from './utils/ErrorReporter.js';
17
19
  export { ComponentManager } from './utils/ComponentManager.class.js';
18
20
  export { AutocompleteManager } from './utils/AutocompleteManager.class.js';
21
+ export { PaginatedEmbed } from './utils/PaginatedEmbed.class.js';
19
22
  // Errors
20
23
  export { InteractionError } from './classes/InteractionError.class.js';
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Options for loading commands from a directory
3
+ */
4
+ export interface LoadCommandsOptions {
5
+ /**
6
+ * Whether to recursively search subdirectories (default: true)
7
+ */
8
+ recursive?: boolean;
9
+ /**
10
+ * Whether to log verbose output to console (default: false)
11
+ */
12
+ verbose?: boolean;
13
+ /**
14
+ * Directory names to skip (default: ['subcommands', 'utils', 'helpers', 'lib'])
15
+ * Case-insensitive partial matching
16
+ */
17
+ skipDirs?: string[];
18
+ /**
19
+ * File name patterns to skip (default: ['Helper', 'Util', '.test', '.spec'])
20
+ * Case-sensitive partial matching
21
+ */
22
+ skipFiles?: string[];
23
+ /**
24
+ * Custom filter function for fine-grained control (optional)
25
+ * Return true to include the file, false to skip
26
+ * Runs AFTER skipDirs and skipFiles checks
27
+ */
28
+ filter?: (filePath: string) => boolean;
29
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,5 +1,5 @@
1
1
  import Stream from 'stream';
2
- import { ActionRowBuilder, APIAttachment, Attachment, AttachmentBuilder, AttachmentPayload, BufferResolvable, ButtonInteraction, ChannelSelectMenuInteraction, ChatInputCommandInteraction, EmbedBuilder, JSONEncodable, Message, ModalSubmitInteraction, StringSelectMenuInteraction } from 'discord.js';
2
+ import { ActionRowBuilder, APIAttachment, Attachment, AttachmentBuilder, AttachmentPayload, BufferResolvable, ButtonInteraction, ChannelSelectMenuInteraction, ChatInputCommandInteraction, ContextMenuCommandInteraction, EmbedBuilder, JSONEncodable, Message, ModalSubmitInteraction, StringSelectMenuInteraction } from 'discord.js';
3
3
  /**
4
4
  * Safely replies to an interaction, handling deferred/replied states.
5
5
  *
@@ -23,7 +23,7 @@ import { ActionRowBuilder, APIAttachment, Attachment, AttachmentBuilder, Attachm
23
23
  * }
24
24
  * ```
25
25
  */
26
- export declare function safeReply(interaction: ChatInputCommandInteraction | ButtonInteraction | ModalSubmitInteraction | ChannelSelectMenuInteraction | StringSelectMenuInteraction, content: string, ephemeral?: boolean, embeds?: EmbedBuilder[], components?: ActionRowBuilder<any>[], files?: (BufferResolvable | Stream | JSONEncodable<APIAttachment> | Attachment | AttachmentBuilder | AttachmentPayload)[]): Promise<Message>;
26
+ export declare function safeReply(interaction: ChatInputCommandInteraction | ButtonInteraction | ModalSubmitInteraction | ChannelSelectMenuInteraction | StringSelectMenuInteraction | ContextMenuCommandInteraction, content: string, ephemeral?: boolean, embeds?: EmbedBuilder[], components?: ActionRowBuilder<any>[], files?: (BufferResolvable | Stream | JSONEncodable<APIAttachment> | Attachment | AttachmentBuilder | AttachmentPayload)[]): Promise<Message>;
27
27
  /**
28
28
  * Safely edits an interaction reply.
29
29
  *
@@ -0,0 +1,27 @@
1
+ import { LoadCommandsOptions } from '../types/loadCommands';
2
+ /**
3
+ * @experimental
4
+ * Automatically discover and load all commands from a directory.
5
+ *
6
+ * Returns an array of command instances that can be registered with CommandManager.
7
+ *
8
+ * @param dirPath - Absolute path to the directory containing commands
9
+ * @param options - Optional configuration
10
+ * @returns Array of command instances
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * import { loadCommands } from '@julanzw/ttoolbox-discordjs-framework';
15
+ *
16
+ * // Load all commands
17
+ * const commands = await loadCommands('./src/commands', { verbose: true });
18
+ *
19
+ * // Register them
20
+ * commandManager.registerCommand(commands);
21
+ *
22
+ * // Or filter/process them first
23
+ * const adminCommands = commands.filter(cmd => cmd.name.startsWith('admin'));
24
+ * commandManager.registerCommand(adminCommands);
25
+ * ```
26
+ */
27
+ export declare function loadCommands(dirPath: string, options?: LoadCommandsOptions): Promise<any[]>;
@@ -0,0 +1,129 @@
1
+ import { readdir, stat } from 'fs/promises';
2
+ import { join, extname } from 'path';
3
+ import { pathToFileURL } from 'url';
4
+ /**
5
+ * @experimental
6
+ * Automatically discover and load all commands from a directory.
7
+ *
8
+ * Returns an array of command instances that can be registered with CommandManager.
9
+ *
10
+ * @param dirPath - Absolute path to the directory containing commands
11
+ * @param options - Optional configuration
12
+ * @returns Array of command instances
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * import { loadCommands } from '@julanzw/ttoolbox-discordjs-framework';
17
+ *
18
+ * // Load all commands
19
+ * const commands = await loadCommands('./src/commands', { verbose: true });
20
+ *
21
+ * // Register them
22
+ * commandManager.registerCommand(commands);
23
+ *
24
+ * // Or filter/process them first
25
+ * const adminCommands = commands.filter(cmd => cmd.name.startsWith('admin'));
26
+ * commandManager.registerCommand(adminCommands);
27
+ * ```
28
+ */
29
+ export async function loadCommands(dirPath, options = {}) {
30
+ const { recursive = true, verbose = false, skipDirs = ['subcommands', 'utils', 'helpers', 'lib'], skipFiles = ['Helper', 'Util', '.test', '.spec'], filter, } = options;
31
+ const allCommands = [];
32
+ try {
33
+ const files = await getFilesRecursive(dirPath, recursive, skipDirs, skipFiles, filter);
34
+ if (verbose) {
35
+ console.log(`Found ${files.length} potential command files in ${dirPath}`);
36
+ }
37
+ for (const file of files) {
38
+ try {
39
+ const commands = await loadCommandsFromFile(file, verbose);
40
+ allCommands.push(...commands);
41
+ }
42
+ catch (err) {
43
+ if (verbose) {
44
+ console.error(`⚠️ Failed to load ${file}: ${err.message}`);
45
+ }
46
+ }
47
+ }
48
+ if (verbose) {
49
+ console.log(`\n✅ Successfully loaded ${allCommands.length} commands`);
50
+ }
51
+ return allCommands;
52
+ }
53
+ catch (err) {
54
+ throw new Error(`Failed to load commands from ${dirPath}: ${err.message}`);
55
+ }
56
+ }
57
+ /**
58
+ * Recursively get all .js/.ts files from a directory with smart filtering
59
+ */
60
+ async function getFilesRecursive(dirPath, recursive, skipDirs, skipFiles, customFilter) {
61
+ const files = [];
62
+ const entries = await readdir(dirPath);
63
+ for (const entry of entries) {
64
+ const fullPath = join(dirPath, entry);
65
+ const stats = await stat(fullPath);
66
+ if (stats.isDirectory()) {
67
+ if (skipDirs.some(skip => entry.toLowerCase().includes(skip.toLowerCase()))) {
68
+ continue;
69
+ }
70
+ if (recursive) {
71
+ const subFiles = await getFilesRecursive(fullPath, recursive, skipDirs, skipFiles, customFilter);
72
+ files.push(...subFiles);
73
+ }
74
+ }
75
+ else {
76
+ const ext = extname(entry);
77
+ if (ext !== '.js' && ext !== '.ts')
78
+ continue;
79
+ if (skipFiles.some(skip => entry.includes(skip)))
80
+ continue;
81
+ if (customFilter && !customFilter(fullPath))
82
+ continue;
83
+ files.push(fullPath);
84
+ }
85
+ }
86
+ return files;
87
+ }
88
+ /**
89
+ * Load all command exports from a single file
90
+ */
91
+ async function loadCommandsFromFile(filePath, verbose) {
92
+ const commands = [];
93
+ const fileUrl = pathToFileURL(filePath).href;
94
+ const module = await import(fileUrl);
95
+ if (module.default) {
96
+ commands.push(module.default);
97
+ if (verbose) {
98
+ const type = getCommandType(module.default);
99
+ const name = module.default.name || 'unnamed';
100
+ console.log(` ✓ ${type}: ${name}`);
101
+ }
102
+ }
103
+ for (const [exportName, exportValue] of Object.entries(module)) {
104
+ if (exportName === 'default')
105
+ continue;
106
+ commands.push(exportValue);
107
+ if (verbose) {
108
+ const type = getCommandType(exportValue);
109
+ const name = exportValue.name || exportName;
110
+ console.log(` ✓ ${type}: ${name}`);
111
+ }
112
+ }
113
+ return commands;
114
+ }
115
+ /**
116
+ * Get a human-readable type name for a command
117
+ */
118
+ function getCommandType(obj) {
119
+ const constructorName = obj?.constructor?.name;
120
+ if (constructorName?.includes('SubcommandGroup'))
121
+ return 'SubcommandGroup';
122
+ if (constructorName?.includes('UserContextMenu'))
123
+ return 'UserContextMenu';
124
+ if (constructorName?.includes('MessageContextMenu'))
125
+ return 'MessageContextMenu';
126
+ if (constructorName?.includes('Command'))
127
+ return 'Command';
128
+ return 'Unknown';
129
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@julanzw/ttoolbox-discordjs-framework",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "A Discord.js command framework with built-in handlers and utilities",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",