@julanzw/ttoolbox-discordjs-framework 1.1.0 → 1.3.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/dist/index.d.ts CHANGED
@@ -8,6 +8,8 @@ export type { PermissionLevel } from './types/permission.js';
8
8
  export type { Modal, ModalField } from './types/modal.js';
9
9
  export type { ButtonType } from './types/button.js';
10
10
  export type { ILogger } from './types/logger.js';
11
+ export type { AnySelectMenuInteraction, ButtonHandler, SelectMenuHandler, ComponentConfig } from './types/component.js';
12
+ export type { AutocompleteHandler } from './types/autocomplete.js';
11
13
  export { getPermissionsForLevel } from './utils/permissions.js';
12
14
  export { embedBuilder, createButton, createButtonsRow, createPaginationButtons, } from './utils/embeds.js';
13
15
  export { stringOption, integerOption, booleanOption, userOption, channelOption, roleOption, } from './utils/slashCommandOptions.js';
@@ -16,4 +18,6 @@ export { formatDuration, formatDateToString, formatDateToYYYYMMDDHHMMSS, formatD
16
18
  export { TIMES_MILISECONDS } from './utils/miliseconds.js';
17
19
  export { TToolboxLogger } from './utils/TToolboxLogger.class.js';
18
20
  export { ErrorReporter } from './utils/ErrorReporter.js';
21
+ export { ComponentManager } from './utils/ComponentManager.class.js';
22
+ export { AutocompleteManager } from './utils/AutocompleteManager.class.js';
19
23
  export { InteractionError } from './classes/InteractionError.class.js';
package/dist/index.js CHANGED
@@ -14,5 +14,7 @@ export { formatDuration, formatDateToString, formatDateToYYYYMMDDHHMMSS, formatD
14
14
  export { TIMES_MILISECONDS } from './utils/miliseconds.js';
15
15
  export { TToolboxLogger } from './utils/TToolboxLogger.class.js';
16
16
  export { ErrorReporter } from './utils/ErrorReporter.js';
17
+ export { ComponentManager } from './utils/ComponentManager.class.js';
18
+ export { AutocompleteManager } from './utils/AutocompleteManager.class.js';
17
19
  // Errors
18
20
  export { InteractionError } from './classes/InteractionError.class.js';
@@ -0,0 +1,25 @@
1
+ import { AutocompleteInteraction } from "discord.js";
2
+ /**
3
+ * Handler function for autocomplete interactions.
4
+ *
5
+ * @param interaction - The autocomplete interaction
6
+ * @param focusedValue - The current value the user is typing
7
+ * @returns Array of choices to display (max 25)
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * async (interaction, focusedValue) => {
12
+ * const feeds = await getFeeds(interaction.user.id);
13
+ * return feeds
14
+ * .filter(f => f.name.toLowerCase().includes(focusedValue.toLowerCase()))
15
+ * .map(f => ({ name: f.name, value: f.name }));
16
+ * }
17
+ * ```
18
+ */
19
+ export type AutocompleteHandler = (interaction: AutocompleteInteraction, focusedValue: string) => Promise<Array<{
20
+ name: string;
21
+ value: string;
22
+ }>> | Array<{
23
+ name: string;
24
+ value: string;
25
+ }>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,22 @@
1
+ import { StringSelectMenuInteraction, ChannelSelectMenuInteraction, RoleSelectMenuInteraction, UserSelectMenuInteraction, MentionableSelectMenuInteraction, ButtonInteraction } from "discord.js";
2
+ /**
3
+ * Type for all select menu interactions
4
+ */
5
+ export type AnySelectMenuInteraction = StringSelectMenuInteraction | ChannelSelectMenuInteraction | RoleSelectMenuInteraction | UserSelectMenuInteraction | MentionableSelectMenuInteraction;
6
+ /**
7
+ * Handler function for button interactions
8
+ */
9
+ export type ButtonHandler = (interaction: ButtonInteraction) => Promise<void>;
10
+ /**
11
+ * Handler function for select menu interactions
12
+ */
13
+ export type SelectMenuHandler = (interaction: AnySelectMenuInteraction) => Promise<void>;
14
+ /**
15
+ * Configuration for a component handler
16
+ */
17
+ export interface ComponentConfig {
18
+ /** Whether this component should be removed after being handled once */
19
+ ephemeral?: boolean;
20
+ /** Optional timeout in milliseconds after which the handler is removed */
21
+ timeout?: number;
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,140 @@
1
+ import { AutocompleteInteraction } from 'discord.js';
2
+ import type { ILogger } from '../types/logger.js';
3
+ import { AutocompleteHandler } from '../types/autocomplete.js';
4
+ /**
5
+ * Manages autocomplete interactions for slash commands.
6
+ *
7
+ * Provides centralized registration and handling of autocomplete options.
8
+ * Supports per-command, per-option handlers with automatic filtering and
9
+ * Discord's 25-choice limit.
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * const autocompleteManager = new AutocompleteManager(logger);
14
+ *
15
+ * // Register autocomplete for a specific command option
16
+ * autocompleteManager.register('item', 'name', async (interaction, value) => {
17
+ * const items = await prisma.item.findMany({
18
+ * where: {
19
+ * userId: interaction.user.id,
20
+ * name: { contains: value },
21
+ * },
22
+ * });
23
+ *
24
+ * return items.map(item => ({ name: item.name, value: item.name }));
25
+ * });
26
+ *
27
+ * // Handle autocomplete interactions
28
+ * client.on('interactionCreate', async (interaction) => {
29
+ * if (interaction.isAutocomplete()) {
30
+ * await autocompleteManager.handle(interaction);
31
+ * }
32
+ * });
33
+ * ```
34
+ */
35
+ export declare class AutocompleteManager {
36
+ private logger?;
37
+ private handlers;
38
+ constructor(logger?: ILogger | undefined);
39
+ /**
40
+ * Register an autocomplete handler for a command option.
41
+ *
42
+ * @param commandName - The name of the slash command
43
+ * @param optionName - The name of the option with autocomplete enabled
44
+ * @param handler - The handler function that returns choices
45
+ *
46
+ * @example
47
+ * ```typescript
48
+ * // Simple static choices
49
+ * autocompleteManager.register('config', 'setting', async () => {
50
+ * return [
51
+ * { name: 'Notifications', value: 'notifications' },
52
+ * { name: 'Language', value: 'language' },
53
+ * { name: 'Theme', value: 'theme' },
54
+ * ];
55
+ * });
56
+ *
57
+ * // Dynamic choices from database
58
+ * autocompleteManager.register('item', 'name', async (interaction, value) => {
59
+ * const items = await prisma.item.findMany({
60
+ * where: {
61
+ * userId: interaction.user.id,
62
+ * name: { contains: value },
63
+ * },
64
+ * take: 25,
65
+ * });
66
+ *
67
+ * return items.map(item => ({ name: item.name, value: item.id }));
68
+ * });
69
+ *
70
+ * // With fuzzy search
71
+ * autocompleteManager.register('user', 'username', async (interaction, value) => {
72
+ * const users = await searchUsers(value);
73
+ * return users.map(u => ({ name: u.tag, value: u.id }));
74
+ * });
75
+ * ```
76
+ */
77
+ register(commandName: string, optionName: string, handler: AutocompleteHandler): void;
78
+ /**
79
+ * Handle an autocomplete interaction.
80
+ *
81
+ * Automatically calls the appropriate handler and responds with choices.
82
+ * Handles errors gracefully and enforces Discord's 25-choice limit.
83
+ *
84
+ * @param interaction - The autocomplete interaction to handle
85
+ *
86
+ * @example
87
+ * ```typescript
88
+ * client.on('interactionCreate', async (interaction) => {
89
+ * if (interaction.isAutocomplete()) {
90
+ * await autocompleteManager.handle(interaction);
91
+ * }
92
+ * });
93
+ * ```
94
+ */
95
+ handle(interaction: AutocompleteInteraction): Promise<void>;
96
+ /**
97
+ * Unregister an autocomplete handler.
98
+ *
99
+ * @param commandName - The command name
100
+ * @param optionName - The option name
101
+ * @returns true if the handler was removed, false if it didn't exist
102
+ */
103
+ unregister(commandName: string, optionName: string): boolean;
104
+ /**
105
+ * Unregister all handlers for a command.
106
+ *
107
+ * @param commandName - The command name
108
+ * @returns true if any handlers were removed
109
+ */
110
+ unregisterCommand(commandName: string): boolean;
111
+ /**
112
+ * Check if a handler is registered for a command option.
113
+ *
114
+ * @param commandName - The command name
115
+ * @param optionName - The option name
116
+ * @returns true if a handler exists
117
+ */
118
+ has(commandName: string, optionName: string): boolean;
119
+ /**
120
+ * Get all registered command names.
121
+ */
122
+ getCommands(): string[];
123
+ /**
124
+ * Get all registered option names for a command.
125
+ *
126
+ * @param commandName - The command name
127
+ * @returns Array of option names, or empty array if command not found
128
+ */
129
+ getOptions(commandName: string): string[];
130
+ /**
131
+ * Clear all registered handlers.
132
+ *
133
+ * Useful for cleanup or testing.
134
+ */
135
+ clear(): void;
136
+ /**
137
+ * Get the total number of registered handlers across all commands.
138
+ */
139
+ get handlerCount(): number;
140
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Manages autocomplete interactions for slash commands.
3
+ *
4
+ * Provides centralized registration and handling of autocomplete options.
5
+ * Supports per-command, per-option handlers with automatic filtering and
6
+ * Discord's 25-choice limit.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * const autocompleteManager = new AutocompleteManager(logger);
11
+ *
12
+ * // Register autocomplete for a specific command option
13
+ * autocompleteManager.register('item', 'name', async (interaction, value) => {
14
+ * const items = await prisma.item.findMany({
15
+ * where: {
16
+ * userId: interaction.user.id,
17
+ * name: { contains: value },
18
+ * },
19
+ * });
20
+ *
21
+ * return items.map(item => ({ name: item.name, value: item.name }));
22
+ * });
23
+ *
24
+ * // Handle autocomplete interactions
25
+ * client.on('interactionCreate', async (interaction) => {
26
+ * if (interaction.isAutocomplete()) {
27
+ * await autocompleteManager.handle(interaction);
28
+ * }
29
+ * });
30
+ * ```
31
+ */
32
+ export class AutocompleteManager {
33
+ constructor(logger) {
34
+ this.logger = logger;
35
+ // Map structure: Map<commandName, Map<optionName, handler>>
36
+ this.handlers = new Map();
37
+ }
38
+ /**
39
+ * Register an autocomplete handler for a command option.
40
+ *
41
+ * @param commandName - The name of the slash command
42
+ * @param optionName - The name of the option with autocomplete enabled
43
+ * @param handler - The handler function that returns choices
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * // Simple static choices
48
+ * autocompleteManager.register('config', 'setting', async () => {
49
+ * return [
50
+ * { name: 'Notifications', value: 'notifications' },
51
+ * { name: 'Language', value: 'language' },
52
+ * { name: 'Theme', value: 'theme' },
53
+ * ];
54
+ * });
55
+ *
56
+ * // Dynamic choices from database
57
+ * autocompleteManager.register('item', 'name', async (interaction, value) => {
58
+ * const items = await prisma.item.findMany({
59
+ * where: {
60
+ * userId: interaction.user.id,
61
+ * name: { contains: value },
62
+ * },
63
+ * take: 25,
64
+ * });
65
+ *
66
+ * return items.map(item => ({ name: item.name, value: item.id }));
67
+ * });
68
+ *
69
+ * // With fuzzy search
70
+ * autocompleteManager.register('user', 'username', async (interaction, value) => {
71
+ * const users = await searchUsers(value);
72
+ * return users.map(u => ({ name: u.tag, value: u.id }));
73
+ * });
74
+ * ```
75
+ */
76
+ register(commandName, optionName, handler) {
77
+ if (!this.handlers.has(commandName)) {
78
+ this.handlers.set(commandName, new Map());
79
+ }
80
+ this.handlers.get(commandName).set(optionName, handler);
81
+ this.logger?.info(`Registered autocomplete handler: ${commandName}.${optionName}`, 'autocomplete-manager');
82
+ }
83
+ /**
84
+ * Handle an autocomplete interaction.
85
+ *
86
+ * Automatically calls the appropriate handler and responds with choices.
87
+ * Handles errors gracefully and enforces Discord's 25-choice limit.
88
+ *
89
+ * @param interaction - The autocomplete interaction to handle
90
+ *
91
+ * @example
92
+ * ```typescript
93
+ * client.on('interactionCreate', async (interaction) => {
94
+ * if (interaction.isAutocomplete()) {
95
+ * await autocompleteManager.handle(interaction);
96
+ * }
97
+ * });
98
+ * ```
99
+ */
100
+ async handle(interaction) {
101
+ const commandHandlers = this.handlers.get(interaction.commandName);
102
+ if (!commandHandlers) {
103
+ this.logger?.warn(`No autocomplete handlers for command: ${interaction.commandName}`, 'autocomplete-manager');
104
+ await interaction.respond([]);
105
+ return;
106
+ }
107
+ const focusedOption = interaction.options.getFocused(true);
108
+ const handler = commandHandlers.get(focusedOption.name);
109
+ if (!handler) {
110
+ this.logger?.warn(`No autocomplete handler for ${interaction.commandName}.${focusedOption.name}`, 'autocomplete-manager');
111
+ await interaction.respond([]);
112
+ return;
113
+ }
114
+ try {
115
+ const choices = await handler(interaction, focusedOption.value);
116
+ // Discord's 25-choice limit
117
+ const limitedChoices = choices.slice(0, 25);
118
+ if (choices.length > 25) {
119
+ this.logger?.warn(`Autocomplete handler for ${interaction.commandName}.${focusedOption.name} returned ${choices.length} choices, truncating to 25`, 'autocomplete-manager');
120
+ }
121
+ await interaction.respond(limitedChoices);
122
+ }
123
+ catch (err) {
124
+ this.logger?.error(`Error in autocomplete handler for ${interaction.commandName}.${focusedOption.name}: ${err.message}`, 'autocomplete-manager');
125
+ // Empty array on error to prevent interaction failure
126
+ await interaction.respond([]);
127
+ }
128
+ }
129
+ /**
130
+ * Unregister an autocomplete handler.
131
+ *
132
+ * @param commandName - The command name
133
+ * @param optionName - The option name
134
+ * @returns true if the handler was removed, false if it didn't exist
135
+ */
136
+ unregister(commandName, optionName) {
137
+ const commandHandlers = this.handlers.get(commandName);
138
+ if (!commandHandlers)
139
+ return false;
140
+ const removed = commandHandlers.delete(optionName);
141
+ if (commandHandlers.size === 0) {
142
+ this.handlers.delete(commandName);
143
+ }
144
+ return removed;
145
+ }
146
+ /**
147
+ * Unregister all handlers for a command.
148
+ *
149
+ * @param commandName - The command name
150
+ * @returns true if any handlers were removed
151
+ */
152
+ unregisterCommand(commandName) {
153
+ return this.handlers.delete(commandName);
154
+ }
155
+ /**
156
+ * Check if a handler is registered for a command option.
157
+ *
158
+ * @param commandName - The command name
159
+ * @param optionName - The option name
160
+ * @returns true if a handler exists
161
+ */
162
+ has(commandName, optionName) {
163
+ return this.handlers.get(commandName)?.has(optionName) || false;
164
+ }
165
+ /**
166
+ * Get all registered command names.
167
+ */
168
+ getCommands() {
169
+ return Array.from(this.handlers.keys());
170
+ }
171
+ /**
172
+ * Get all registered option names for a command.
173
+ *
174
+ * @param commandName - The command name
175
+ * @returns Array of option names, or empty array if command not found
176
+ */
177
+ getOptions(commandName) {
178
+ const commandHandlers = this.handlers.get(commandName);
179
+ return commandHandlers ? Array.from(commandHandlers.keys()) : [];
180
+ }
181
+ /**
182
+ * Clear all registered handlers.
183
+ *
184
+ * Useful for cleanup or testing.
185
+ */
186
+ clear() {
187
+ this.handlers.clear();
188
+ this.logger?.info('Cleared all autocomplete handlers', 'autocomplete-manager');
189
+ }
190
+ /**
191
+ * Get the total number of registered handlers across all commands.
192
+ */
193
+ get handlerCount() {
194
+ let count = 0;
195
+ for (const commandHandlers of this.handlers.values()) {
196
+ count += commandHandlers.size;
197
+ }
198
+ return count;
199
+ }
200
+ }
@@ -0,0 +1,194 @@
1
+ import { ButtonInteraction, AnySelectMenuInteraction } from 'discord.js';
2
+ import type { ILogger } from '../types/logger.js';
3
+ import { ButtonHandler, ComponentConfig, SelectMenuHandler } from '../types/component.js';
4
+ /**
5
+ * Manages Discord component interactions (buttons and select menus).
6
+ *
7
+ * Provides centralized registration and handling of long living
8
+ * button clicks and select menu interactions.
9
+ *
10
+ * Supports dynamic custom IDs (e.g., "delete-feed:123"),
11
+ * ephemeral handlers (one-time use), and automatic cleanup.
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * const componentManager = new ComponentManager(logger);
16
+ *
17
+ * // Register a button handler
18
+ * componentManager.registerButton('delete-feed', async (interaction) => {
19
+ * const feedId = interaction.customId.split(':')[1];
20
+ * await deleteFeed(feedId);
21
+ * await interaction.reply('Feed deleted!');
22
+ * });
23
+ *
24
+ * // Register a select menu handler
25
+ * componentManager.registerSelect('choose-feed-type', async (interaction) => {
26
+ * const type = interaction.values[0];
27
+ * await interaction.reply(`You selected: ${type}`);
28
+ * });
29
+ *
30
+ * // Handle interactions
31
+ * client.on('interactionCreate', async (interaction) => {
32
+ * if (interaction.isButton()) {
33
+ * await componentManager.handleButton(interaction);
34
+ * }
35
+ * if (interaction.isAnySelectMenu()) {
36
+ * await componentManager.handleSelect(interaction);
37
+ * }
38
+ * });
39
+ * ```
40
+ */
41
+ export declare class ComponentManager {
42
+ private logger?;
43
+ private buttonHandlers;
44
+ private selectHandlers;
45
+ private timeouts;
46
+ constructor(logger?: ILogger | undefined);
47
+ /**
48
+ * Register a button click handler.
49
+ *
50
+ * Supports dynamic custom IDs - if the exact ID isn't found, attempts to match
51
+ * using the base ID (before the first colon).\
52
+ * This allows for buttons with dynamic suffixes like "global-delete:123".
53
+ *
54
+ * Its recommended to add a prefix like ```global-``` to the id to fully
55
+ * distingush it from other local component handlers.
56
+ *
57
+ * @param customId - The button's custom ID (or base ID for dynamic buttons)
58
+ * @param handler - Function to call when the button is clicked
59
+ * @param config - Optional configuration (ephemeral, timeout)
60
+ *
61
+ * @example
62
+ * ```typescript
63
+ * // Simple button
64
+ * componentManager.registerButton('global-refresh', async (interaction) => {
65
+ * await interaction.reply('Refreshed!');
66
+ * });
67
+ *
68
+ * // Dynamic button (matches "global-delete:123", "global-delete:456", etc.)
69
+ * componentManager.registerButton('global-delete', async (interaction) => {
70
+ * const id = interaction.customId.split(':')[1];
71
+ * await deleteItem(id);
72
+ * });
73
+ *
74
+ * // Ephemeral button (one-time use)
75
+ * componentManager.registerButton('global-confirm', async (interaction) => {
76
+ * await processConfirmation();
77
+ * }, { ephemeral: true });
78
+ *
79
+ * // Button with timeout (auto-remove after 5 minutes)
80
+ * componentManager.registerButton('global-temp-action', async (interaction) => {
81
+ * await doTempAction();
82
+ * }, { timeout: 5 * 60 * 1000 });
83
+ * ```
84
+ */
85
+ registerButton(customId: string, handler: ButtonHandler, config?: ComponentConfig): void;
86
+ /**
87
+ * Register a select menu handler.
88
+ *
89
+ * Supports dynamic custom IDs - if the exact ID isn't found, attempts to match
90
+ * using the base ID (before the first colon).
91
+ *
92
+ * Its recommended to add a prefix like ```global-``` to the id to fully
93
+ * distingush it from other local component handlers.
94
+ *
95
+ * @param customId - The select menu's custom ID (or base ID for dynamic menus)
96
+ * @param handler - Function to call when a selection is made
97
+ * @param config - Optional configuration (ephemeral, timeout)
98
+ *
99
+ * @example
100
+ * ```typescript
101
+ * // String select menu
102
+ * componentManager.registerSelect('global-choose-type', async (interaction) => {
103
+ * const selected = interaction.values[0];
104
+ * await interaction.reply(`You chose: ${selected}`);
105
+ * });
106
+ *
107
+ * // Dynamic select menu
108
+ * componentManager.registerSelect('global-choose-item', async (interaction) => {
109
+ * const itemId = interaction.customId.split(':')[1];
110
+ * const selected = interaction.values;
111
+ * await processSelection(itemId, selected);
112
+ * });
113
+ * ```
114
+ */
115
+ registerSelect(customId: string, handler: SelectMenuHandler, config?: ComponentConfig): void;
116
+ /**
117
+ * Handle a button interaction.
118
+ *
119
+ * Automatically matches dynamic IDs and removes ephemeral handlers after use.
120
+ *
121
+ * @param interaction - The button interaction to handle
122
+ * @throws {Error} If no handler is found for the button
123
+ */
124
+ handleButton(interaction: ButtonInteraction): Promise<void>;
125
+ /**
126
+ * Handle a select menu interaction.
127
+ *
128
+ * Automatically matches dynamic IDs and removes ephemeral handlers after use.
129
+ *
130
+ * @param interaction - The select menu interaction to handle
131
+ * @throws {Error} If no handler is found for the select menu
132
+ */
133
+ handleSelect(interaction: AnySelectMenuInteraction): Promise<void>;
134
+ /**
135
+ * Find a handler by custom ID, supporting dynamic IDs.
136
+ *
137
+ * First tries exact match, then tries base ID (before colon).
138
+ */
139
+ private findHandler;
140
+ /**
141
+ * Set up automatic removal timeout for a handler.
142
+ */
143
+ private setupTimeout;
144
+ /**
145
+ * Unregister a button handler.
146
+ *
147
+ * @param customId - The button's custom ID
148
+ * @returns true if the handler was removed, false if it didn't exist
149
+ */
150
+ unregisterButton(customId: string): boolean;
151
+ /**
152
+ * Unregister a select menu handler.
153
+ *
154
+ * @param customId - The select menu's custom ID
155
+ * @returns true if the handler was removed, false if it didn't exist
156
+ */
157
+ unregisterSelect(customId: string): boolean;
158
+ /**
159
+ * Check if a button handler is registered.
160
+ *
161
+ * @param customId - The button's custom ID
162
+ * @returns true if a handler exists (exact or base ID match)
163
+ */
164
+ hasButton(customId: string): boolean;
165
+ /**
166
+ * Check if a select menu handler is registered.
167
+ *
168
+ * @param customId - The select menu's custom ID
169
+ * @returns true if a handler exists (exact or base ID match)
170
+ */
171
+ hasSelect(customId: string): boolean;
172
+ /**
173
+ * Clear all registered handlers.
174
+ *
175
+ * Useful for cleanup during bot shutdown or testing.
176
+ */
177
+ clear(): void;
178
+ /**
179
+ * Get the total number of registered button handlers.
180
+ */
181
+ get buttonCount(): number;
182
+ /**
183
+ * Get the total number of registered select menu handlers.
184
+ */
185
+ get selectCount(): number;
186
+ /**
187
+ * Get all registered button IDs.
188
+ */
189
+ getButtonIds(): string[];
190
+ /**
191
+ * Get all registered select menu IDs.
192
+ */
193
+ getSelectIds(): string[];
194
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Manages Discord component interactions (buttons and select menus).
3
+ *
4
+ * Provides centralized registration and handling of long living
5
+ * button clicks and select menu interactions.
6
+ *
7
+ * Supports dynamic custom IDs (e.g., "delete-feed:123"),
8
+ * ephemeral handlers (one-time use), and automatic cleanup.
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * const componentManager = new ComponentManager(logger);
13
+ *
14
+ * // Register a button handler
15
+ * componentManager.registerButton('delete-feed', async (interaction) => {
16
+ * const feedId = interaction.customId.split(':')[1];
17
+ * await deleteFeed(feedId);
18
+ * await interaction.reply('Feed deleted!');
19
+ * });
20
+ *
21
+ * // Register a select menu handler
22
+ * componentManager.registerSelect('choose-feed-type', async (interaction) => {
23
+ * const type = interaction.values[0];
24
+ * await interaction.reply(`You selected: ${type}`);
25
+ * });
26
+ *
27
+ * // Handle interactions
28
+ * client.on('interactionCreate', async (interaction) => {
29
+ * if (interaction.isButton()) {
30
+ * await componentManager.handleButton(interaction);
31
+ * }
32
+ * if (interaction.isAnySelectMenu()) {
33
+ * await componentManager.handleSelect(interaction);
34
+ * }
35
+ * });
36
+ * ```
37
+ */
38
+ export class ComponentManager {
39
+ constructor(logger) {
40
+ this.logger = logger;
41
+ this.buttonHandlers = new Map();
42
+ this.selectHandlers = new Map();
43
+ this.timeouts = new Map();
44
+ }
45
+ /**
46
+ * Register a button click handler.
47
+ *
48
+ * Supports dynamic custom IDs - if the exact ID isn't found, attempts to match
49
+ * using the base ID (before the first colon).\
50
+ * This allows for buttons with dynamic suffixes like "global-delete:123".
51
+ *
52
+ * Its recommended to add a prefix like ```global-``` to the id to fully
53
+ * distingush it from other local component handlers.
54
+ *
55
+ * @param customId - The button's custom ID (or base ID for dynamic buttons)
56
+ * @param handler - Function to call when the button is clicked
57
+ * @param config - Optional configuration (ephemeral, timeout)
58
+ *
59
+ * @example
60
+ * ```typescript
61
+ * // Simple button
62
+ * componentManager.registerButton('global-refresh', async (interaction) => {
63
+ * await interaction.reply('Refreshed!');
64
+ * });
65
+ *
66
+ * // Dynamic button (matches "global-delete:123", "global-delete:456", etc.)
67
+ * componentManager.registerButton('global-delete', async (interaction) => {
68
+ * const id = interaction.customId.split(':')[1];
69
+ * await deleteItem(id);
70
+ * });
71
+ *
72
+ * // Ephemeral button (one-time use)
73
+ * componentManager.registerButton('global-confirm', async (interaction) => {
74
+ * await processConfirmation();
75
+ * }, { ephemeral: true });
76
+ *
77
+ * // Button with timeout (auto-remove after 5 minutes)
78
+ * componentManager.registerButton('global-temp-action', async (interaction) => {
79
+ * await doTempAction();
80
+ * }, { timeout: 5 * 60 * 1000 });
81
+ * ```
82
+ */
83
+ registerButton(customId, handler, config = {}) {
84
+ this.buttonHandlers.set(customId, { handler, config });
85
+ // Set up auto-removal timeout if specified
86
+ if (config.timeout) {
87
+ this.setupTimeout('button', customId, config.timeout);
88
+ }
89
+ this.logger?.info(`Registered button handler: ${customId}${config.ephemeral ? ' (ephemeral)' : ''}${config.timeout ? ` (timeout: ${config.timeout}ms)` : ''}`, 'component-manager');
90
+ }
91
+ /**
92
+ * Register a select menu handler.
93
+ *
94
+ * Supports dynamic custom IDs - if the exact ID isn't found, attempts to match
95
+ * using the base ID (before the first colon).
96
+ *
97
+ * Its recommended to add a prefix like ```global-``` to the id to fully
98
+ * distingush it from other local component handlers.
99
+ *
100
+ * @param customId - The select menu's custom ID (or base ID for dynamic menus)
101
+ * @param handler - Function to call when a selection is made
102
+ * @param config - Optional configuration (ephemeral, timeout)
103
+ *
104
+ * @example
105
+ * ```typescript
106
+ * // String select menu
107
+ * componentManager.registerSelect('global-choose-type', async (interaction) => {
108
+ * const selected = interaction.values[0];
109
+ * await interaction.reply(`You chose: ${selected}`);
110
+ * });
111
+ *
112
+ * // Dynamic select menu
113
+ * componentManager.registerSelect('global-choose-item', async (interaction) => {
114
+ * const itemId = interaction.customId.split(':')[1];
115
+ * const selected = interaction.values;
116
+ * await processSelection(itemId, selected);
117
+ * });
118
+ * ```
119
+ */
120
+ registerSelect(customId, handler, config = {}) {
121
+ this.selectHandlers.set(customId, { handler, config });
122
+ if (config.timeout) {
123
+ this.setupTimeout('select', customId, config.timeout);
124
+ }
125
+ this.logger?.info(`Registered select menu handler: ${customId}${config.ephemeral ? ' (ephemeral)' : ''}${config.timeout ? ` (timeout: ${config.timeout}ms)` : ''}`, 'component-manager');
126
+ }
127
+ /**
128
+ * Handle a button interaction.
129
+ *
130
+ * Automatically matches dynamic IDs and removes ephemeral handlers after use.
131
+ *
132
+ * @param interaction - The button interaction to handle
133
+ * @throws {Error} If no handler is found for the button
134
+ */
135
+ async handleButton(interaction) {
136
+ if (interaction.replied || interaction.deferred) {
137
+ this.logger?.warn(`Button ${interaction.customId} already handled, skipping`, 'component-manager');
138
+ return;
139
+ }
140
+ const entry = this.findHandler(this.buttonHandlers, interaction.customId);
141
+ if (!entry) {
142
+ throw new Error(`No handler registered for button: ${interaction.customId}`);
143
+ }
144
+ const { handler, config, matchedId } = entry;
145
+ try {
146
+ await handler(interaction);
147
+ // Remove ephemeral handlers after use
148
+ if (config.ephemeral) {
149
+ this.unregisterButton(matchedId);
150
+ this.logger?.info(`Removed ephemeral button handler: ${matchedId}`, 'component-manager');
151
+ }
152
+ }
153
+ catch (err) {
154
+ this.logger?.error(`Error handling button ${interaction.customId}: ${err.message}`, 'component-manager');
155
+ throw err;
156
+ }
157
+ }
158
+ /**
159
+ * Handle a select menu interaction.
160
+ *
161
+ * Automatically matches dynamic IDs and removes ephemeral handlers after use.
162
+ *
163
+ * @param interaction - The select menu interaction to handle
164
+ * @throws {Error} If no handler is found for the select menu
165
+ */
166
+ async handleSelect(interaction) {
167
+ if (interaction.replied || interaction.deferred) {
168
+ this.logger?.warn(`Select menu ${interaction.customId} already handled, skipping`, 'component-manager');
169
+ return;
170
+ }
171
+ const entry = this.findHandler(this.selectHandlers, interaction.customId);
172
+ if (!entry) {
173
+ throw new Error(`No handler registered for select menu: ${interaction.customId}`);
174
+ }
175
+ const { handler, config, matchedId } = entry;
176
+ try {
177
+ await handler(interaction);
178
+ if (config.ephemeral) {
179
+ this.unregisterSelect(matchedId);
180
+ this.logger?.info(`Removed ephemeral select menu handler: ${matchedId}`, 'component-manager');
181
+ }
182
+ }
183
+ catch (err) {
184
+ this.logger?.error(`Error handling select menu ${interaction.customId}: ${err.message}`, 'component-manager');
185
+ throw err;
186
+ }
187
+ }
188
+ /**
189
+ * Find a handler by custom ID, supporting dynamic IDs.
190
+ *
191
+ * First tries exact match, then tries base ID (before colon).
192
+ */
193
+ findHandler(map, customId) {
194
+ // Try exact match first
195
+ const exact = map.get(customId);
196
+ if (exact) {
197
+ return { ...exact, matchedId: customId };
198
+ }
199
+ // Try base ID (before colon) for dynamic IDs
200
+ const baseId = customId.split(':')[0];
201
+ const base = map.get(baseId);
202
+ if (base) {
203
+ return { ...base, matchedId: baseId };
204
+ }
205
+ return null;
206
+ }
207
+ /**
208
+ * Set up automatic removal timeout for a handler.
209
+ */
210
+ setupTimeout(type, customId, timeout) {
211
+ // Clear existing timeout if any
212
+ const existingTimeout = this.timeouts.get(`${type}:${customId}`);
213
+ if (existingTimeout) {
214
+ clearTimeout(existingTimeout);
215
+ }
216
+ // Set new timeout
217
+ const timeoutId = setTimeout(() => {
218
+ if (type === 'button') {
219
+ this.unregisterButton(customId);
220
+ }
221
+ else {
222
+ this.unregisterSelect(customId);
223
+ }
224
+ this.logger?.info(`Auto-removed ${type} handler after timeout: ${customId}`, 'component-manager');
225
+ }, timeout);
226
+ this.timeouts.set(`${type}:${customId}`, timeoutId);
227
+ }
228
+ /**
229
+ * Unregister a button handler.
230
+ *
231
+ * @param customId - The button's custom ID
232
+ * @returns true if the handler was removed, false if it didn't exist
233
+ */
234
+ unregisterButton(customId) {
235
+ const removed = this.buttonHandlers.delete(customId);
236
+ // Clear timeout if exists
237
+ const timeoutId = this.timeouts.get(`button:${customId}`);
238
+ if (timeoutId) {
239
+ clearTimeout(timeoutId);
240
+ this.timeouts.delete(`button:${customId}`);
241
+ }
242
+ return removed;
243
+ }
244
+ /**
245
+ * Unregister a select menu handler.
246
+ *
247
+ * @param customId - The select menu's custom ID
248
+ * @returns true if the handler was removed, false if it didn't exist
249
+ */
250
+ unregisterSelect(customId) {
251
+ const removed = this.selectHandlers.delete(customId);
252
+ const timeoutId = this.timeouts.get(`select:${customId}`);
253
+ if (timeoutId) {
254
+ clearTimeout(timeoutId);
255
+ this.timeouts.delete(`select:${customId}`);
256
+ }
257
+ return removed;
258
+ }
259
+ /**
260
+ * Check if a button handler is registered.
261
+ *
262
+ * @param customId - The button's custom ID
263
+ * @returns true if a handler exists (exact or base ID match)
264
+ */
265
+ hasButton(customId) {
266
+ return this.buttonHandlers.has(customId) ||
267
+ this.buttonHandlers.has(customId.split(':')[0]);
268
+ }
269
+ /**
270
+ * Check if a select menu handler is registered.
271
+ *
272
+ * @param customId - The select menu's custom ID
273
+ * @returns true if a handler exists (exact or base ID match)
274
+ */
275
+ hasSelect(customId) {
276
+ return this.selectHandlers.has(customId) ||
277
+ this.selectHandlers.has(customId.split(':')[0]);
278
+ }
279
+ /**
280
+ * Clear all registered handlers.
281
+ *
282
+ * Useful for cleanup during bot shutdown or testing.
283
+ */
284
+ clear() {
285
+ this.buttonHandlers.clear();
286
+ this.selectHandlers.clear();
287
+ // Clear all timeouts
288
+ for (const timeoutId of this.timeouts.values()) {
289
+ clearTimeout(timeoutId);
290
+ }
291
+ this.timeouts.clear();
292
+ this.logger?.info('Cleared all component handlers', 'component-manager');
293
+ }
294
+ /**
295
+ * Get the total number of registered button handlers.
296
+ */
297
+ get buttonCount() {
298
+ return this.buttonHandlers.size;
299
+ }
300
+ /**
301
+ * Get the total number of registered select menu handlers.
302
+ */
303
+ get selectCount() {
304
+ return this.selectHandlers.size;
305
+ }
306
+ /**
307
+ * Get all registered button IDs.
308
+ */
309
+ getButtonIds() {
310
+ return Array.from(this.buttonHandlers.keys());
311
+ }
312
+ /**
313
+ * Get all registered select menu IDs.
314
+ */
315
+ getSelectIds() {
316
+ return Array.from(this.selectHandlers.keys());
317
+ }
318
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@julanzw/ttoolbox-discordjs-framework",
3
- "version": "1.1.0",
3
+ "version": "1.3.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",