@somehiddenkey/discord-command-utils 2.5.1 → 2.7.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/README.md CHANGED
@@ -144,8 +144,32 @@ The same exists for:
144
144
  - Selection menu : `OnSelectMenu`
145
145
  - Message : `OnMessageCommand`
146
146
  - Modal submission : `OnModal`
147
+
148
+ A slash command with a specific subcommand can be defined as follows:
149
+ ```js
150
+ Interaction.OnSlashCommand(
151
+ {name: "ping", subcommand: "webping"},
152
+ InteractionScope.Main,
153
+ async (interaction) => await interaction.reply("pong")
154
+ );
155
+ ```
156
+ ### Callback arguments
157
+ #### Message arguments
158
+ Message commands start with a prefix, after which the proceeding arguments are the arguments. E.g. the message `!ping "google.com"` would trigger `async (interaction, website) => //...`
159
+
160
+ #### Interaction IDs
161
+ You can burry hidden information in the custom ID of a selection menu, button and modal submission by concatting them to them to the custom ID. The helper function `Interaction.makeCustomId()` exists for this. This is handy to e.g. hide session IDs as not to have to recompute them. Example:
162
+ ```js
163
+ new ButtonBuilder().setCustomId(Interaction.makeCustomId("ping", session_id))
164
+
165
+ Interaction.OnButton(
166
+ "ping", InteractionScope.Main,
167
+ async (interaction, session_id) => await interaction.reply(`pong from ${session_id}`)
168
+ );
169
+ ```
170
+
147
171
  ### Call Interaction
148
- All interactions get stored in a so-called `InteractionContainer` that still needs to be called:
172
+ All interactions get stored in a so-called `InteractionContainer` that still needs to be called. If someone goes wrong in an unexpected way, the interaction will be replied to with "something went wrong" and a consola error will be displayed. Optionally, you can provide an extra error handler as second argument to the interaction caller.
149
173
  ```js
150
174
  const interactionContainer = new InteractionContainer(localConfig, globalConfig);
151
175
 
@@ -164,7 +188,7 @@ For slash commands to work, you need to register them to a bot. There are two ki
164
188
 
165
189
  You can add a set of slash commands through the following abstraction:
166
190
  ```js
167
- const rest = interactionContainer.Rest
191
+ const rest = await interactionContainer.Rest
168
192
  .add([firstSlashCommand, secondSlashCommand], localConfig.guilds.tutor)
169
193
  .add([firstSlashCommand, secondSlashCommand], localConfig.guilds.community)
170
194
  .add([somePublicSlashCommand])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@somehiddenkey/discord-command-utils",
3
- "version": "2.5.1",
3
+ "version": "2.7.0",
4
4
  "description": "A utility library for building Discord bot commands using discord.js",
5
5
  "author": {
6
6
  "email": "k3y.throwaway@gmail.com",
@@ -2,8 +2,8 @@
2
2
  * @typedef {string} Snowflake
3
3
  */
4
4
 
5
- import consola from 'consola';
6
5
  import { Client } from 'discord.js';
6
+ import { consolaConfig } from '../Utils/consola.js';
7
7
 
8
8
  export default class Fetchable {
9
9
  /** @type {string[]} */
@@ -30,7 +30,7 @@ export default class Fetchable {
30
30
  if (prop === "refreshFromBot") {
31
31
  return async () => {
32
32
  cachedObject = await fetcher(id);
33
- consola.debug(`Fetched config object with ID ${id}`);
33
+ consolaConfig.debug(`Fetched config object with ID ${id}`);
34
34
  return cachedObject;
35
35
  };
36
36
  }
@@ -41,7 +41,7 @@ export default class Fetchable {
41
41
  cachedObject = await fetcher(id);
42
42
  if (!cachedObject)
43
43
  throw new Error(`Cannot fetch config object for ID "${id}"`);
44
- consola.debug(`Fetched config object with ID ${id}`);
44
+ consolaConfig.debug(`Fetched config object with ID ${id}`);
45
45
  }
46
46
  return cachedObject;
47
47
  };
@@ -23,7 +23,7 @@ import GuildStaff from './GlobalConfigStaff.js';
23
23
  import { GuildBased } from '../NestedGlobalConfig.js';
24
24
  import { Client } from 'discord.js';
25
25
  import ConfigError from '../ConfigError.js';
26
- import consola from 'consola';
26
+ import { consolaConfig } from '../../Utils/consola.js';
27
27
 
28
28
  export default class GlobalConfig {
29
29
  /** @type {GuildMain} */
@@ -47,7 +47,7 @@ export default class GlobalConfig {
47
47
  const raw = fs.readFileSync(path, { encoding: 'utf8', flag: 'r' });
48
48
  json = JSON.parse(raw);
49
49
  const g = new GlobalConfig(json.guilds, bot);
50
- consola.start(`Loaded GlobalConfig from path: ${path}`);
50
+ consolaConfig.start(`Loaded GlobalConfig from path: ${path}`);
51
51
  return g;
52
52
  } catch (error) {
53
53
  throw new ConfigError(`Failed to load GlobalConfig from path: ${path}`, error);
@@ -60,13 +60,13 @@ export default class GlobalConfig {
60
60
  */
61
61
  get_scope(guild_id) {
62
62
  switch(guild_id) {
63
- case this.main:
63
+ case this.main.guild.id:
64
64
  return InteractionScope.Main;
65
- case this.community:
65
+ case this.community.guild.id:
66
66
  return InteractionScope.Community;
67
- case this.tutor:
67
+ case this.tutor.guild.id:
68
68
  return InteractionScope.Tutor;
69
- case this.staff:
69
+ case this.staff.guild.id:
70
70
  return InteractionScope.Staff;
71
71
  default:
72
72
  return InteractionScope.OtherGuild;
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import ConfigError from "./ConfigError.js";
3
- import consola from 'consola';
3
+ import { consolaConfig } from '../Utils/consola.js';
4
4
 
5
5
  /**
6
6
  * @typedef {string} Snowflake
@@ -10,26 +10,29 @@ export default class LocalConfig {
10
10
  /** @type {string?} */
11
11
  prefix;
12
12
  /** @type {Snowflake} */
13
- clientId;
13
+ #client_id;
14
14
 
15
15
  /** @type {object} */
16
16
  local;
17
17
  /** @type {object} */
18
- restOptions;
18
+ #rest_options;
19
19
  /** @type {object} */
20
20
  activity;
21
21
 
22
22
  constructor(data) {
23
23
  this.prefix = data.prefix;
24
- this.clientId = data.client_id;
24
+ this.#client_id = data.client_id;
25
25
  this.local = data.local || {};
26
- this.restOptions = data.rest_options || {};
26
+ this.#rest_options = data.rest_options || {};
27
27
  this.activity = data.activity || {};
28
28
 
29
- if(!this.clientId)
29
+ if(!this.#client_id)
30
30
  throw new ConfigError('No client_id initialized for LocalConfig');
31
31
  }
32
32
 
33
+ get clientId() { return this.#client_id; }
34
+ get restOptions() { return this.#rest_options; }
35
+
33
36
  /**
34
37
  * Loads the JSON file and returns a GlobalConfig instance.
35
38
  * @param {string} path
@@ -40,9 +43,48 @@ export default class LocalConfig {
40
43
  try {
41
44
  const raw = fs.readFileSync(path, { encoding: 'utf8', flag: 'r' });
42
45
  json = JSON.parse(raw);
43
- return new LocalConfig(json);
46
+ const localConfig =new LocalConfig(json);
47
+ localConfig.#reloadable(path, "localConfig");
48
+ consolaConfig.start(`Loaded LocalConfig from path: ${path}`);
49
+ return localConfig;
44
50
  } catch (error) {
45
51
  throw new ConfigError(`Failed to load LocalConfig from path: ${path}`, error);
46
52
  }
47
53
  }
54
+
55
+ #reloadable(filepath, root=""){
56
+ fs.watch(filepath, { encoding: 'utf8' }, (eventType, _) => {
57
+ if (eventType !== 'change') return;
58
+ fs.readFile(filepath, 'utf8', (err, data) => {
59
+ if (err)
60
+ return consolaConfig.error(err);
61
+ else
62
+ LocalConfig.#compareObjects(JSON.parse(data), this, root+".");
63
+ })
64
+ });
65
+ }
66
+
67
+ static #compareObjects(data, currentObject, tree="") {
68
+ Object.entries(data).forEach(([key, newValue]) => {
69
+ const oldValue = currentObject[key]
70
+ const newValueIsObject = typeof newValue === 'object' && !Array.isArray(newValue) && newValue !== null
71
+ const oldValueIsObject = typeof oldValue === 'object' && !Array.isArray(oldValue) && oldValue !== null
72
+
73
+ if(newValueIsObject && oldValueIsObject)
74
+ LocalConfig.#compareObjects(newValue, oldValue, tree+"."+key);
75
+ else {
76
+ if(Array.isArray(newValue) || Array.isArray(oldValue)) {
77
+ const newValueString = JSON.stringify(newValue);
78
+ const oldValueString = JSON.stringify(oldValue);
79
+ if(newValueString != oldValueString) {
80
+ currentObject[key] = newValue
81
+ consolaConfig.warn(`Updated ${tree}${key}\n - Old: ${oldValueString}\n - New: ${newValueString}`);
82
+ }
83
+ } else if (newValue !== oldValue){
84
+ currentObject[key] = newValue
85
+ consolaConfig.warn(`Updated ${tree}${key}\n - Old: ${JSON.stringify(oldValue)}\n - New: ${JSON.stringify(newValue)}`);
86
+ }
87
+ }
88
+ });
89
+ }
48
90
  }
@@ -1,21 +1,10 @@
1
- import consola from "consola";
2
1
  import ConfigError from "./ConfigError.js";
2
+ import { consolaConfig } from "../Utils/consola.js";
3
3
 
4
4
  /**
5
5
  * @typedef {{client: string, connection: {host: string, user: string, password: string, database: string}}} DatabaseConfig
6
6
  */
7
7
 
8
- const levels = {
9
- silent: -1,
10
- fatal: 0,
11
- error: 1,
12
- warn: 2,
13
- info: 3,
14
- debug: 4,
15
- trace: 5,
16
- };
17
- consola.level = levels[process.env.LOG_LEVEL?.toLowerCase() || "info"] ?? 3;
18
-
19
8
  export default class SecretConfig {
20
9
  /** @type {string} */
21
10
  token;
@@ -52,11 +41,11 @@ export default class SecretConfig {
52
41
 
53
42
  databaseConfigs.push(db_config);
54
43
  } catch (error) {
55
- consola.error(`Failed to load database config for section: ${section}`, error);
44
+ consolaConfig.error(`Failed to load database config for section: ${section}`, error);
56
45
  throw error;
57
46
  }
58
47
  }
59
48
  this.databaseConfig = databaseConfigs;
60
- consola.start(`Loaded SecretConfig for environment: ${this.environment}`);
49
+ consolaConfig.start(`Loaded SecretConfig for environment: ${this.environment}`);
61
50
  }
62
51
  }
@@ -15,11 +15,11 @@ const {
15
15
  /**
16
16
  * @typedef { (interaction: DiscordBaseInteraction<CacheType>) => Promise<any> } InteractionFunction
17
17
  * @typedef { (message: OmitPartialGroupDMChannel<Message<boolean>>) => Promise<any> } InteractionMsgFunction
18
- * @typedef { string } InteractionID
18
+ * @typedef { {name: string, subcommand: string | null} | string } InteractionID
19
19
  */
20
20
 
21
21
  export default class Interaction {
22
- /** @type {string} */
22
+ /** @type {InteractionID} */
23
23
  id;
24
24
  /** @type {InteractionType} */
25
25
  type;
@@ -94,4 +94,13 @@ export default class Interaction {
94
94
  static OnModal(custom_id, scope, target) {
95
95
  return Interaction.On(custom_id, InteractionType.Modal, scope, target)
96
96
  }
97
+
98
+ /**
99
+ * Hides arbitrary number of arguments in the custom ID
100
+ * @param {...any} args
101
+ * @returns {string}
102
+ */
103
+ static makeCustomId(...args) {
104
+ return args.map(arg => String(arg)).join("?");
105
+ }
97
106
  }
@@ -2,25 +2,23 @@ import Interaction from "./Interaction.js";
2
2
  import { InteractionScope, InteractionType } from "../Utils/enums.js";
3
3
  import GlobalConfig from "../Configs/GlobalConfig/GlobalConfig.js";
4
4
  import LocalConfig from "../Configs/LocalConfig.js";
5
- import consola from "consola";
6
5
  import CommandError from "./CommandError.js";
7
6
  import RestCommands from './RestCommands.js';
8
- import pkg, { ChannelType } from 'discord.js';
9
- const {
10
- Interaction: DiscordBaseInteraction,
11
- OmitPartialGroupDMChannel,
12
- Message,
13
- CacheType
14
- } = pkg;
7
+ import { ChannelType } from 'discord.js';
8
+ import { consolaInteraction } from "../Utils/consola.js";
15
9
 
16
10
  /**
11
+ * @typedef { import("discord.js").Interaction } DiscordBaseInteraction
12
+ * @typedef { import("discord.js").OmitPartialGroupDMChannel } OmitPartialGroupDMChannel
13
+ * @typedef { import("discord.js").Message } Message
14
+ * @typedef { import("discord.js").CacheType } CacheType
17
15
  * @typedef { (interaction: DiscordBaseInteraction<CacheType>) => Promise<any> } InteractionFunction
18
16
  * @typedef { (message: OmitPartialGroupDMChannel<Message<boolean>>) => Promise<any> } InteractionMsgFunction
19
- * @typedef { string } InteractionID
17
+ * @typedef { import("./Interaction.js").InteractionID } InteractionID
20
18
  */
21
19
 
22
20
  export default class InteractionContainer {
23
- /** @type {Map<InteractionID, Interaction>} */
21
+ /** @type {Map<string, Interaction>} */
24
22
  static #interaction_container = new Map();
25
23
 
26
24
  /** @type {GlobalConfig} */
@@ -29,42 +27,55 @@ export default class InteractionContainer {
29
27
  #local_config;
30
28
 
31
29
  /** @type {RestCommands} */
32
- Rest = new RestCommands(this.#global_config, this.#local_config);
30
+ Rest;
33
31
 
34
32
  /**
35
33
  * @param {InteractionType} type
36
34
  * @param {InteractionScope} scope
37
- * @param {string} id
38
- * @returns {InteractionID}
35
+ * @param {InteractionID} id
36
+ * @returns {string} interction ID
39
37
  */
40
38
  static #make_key(type, scope, id) {
41
- return `${type}__${scope}__${id}`;
39
+ return `${type}__${scope}__${id.subcommand ? `${id.name}>${id.subcommand}` : (id.name || id)}`;
42
40
  }
43
41
 
44
42
  /**
45
- * @param {InteractionType} type
46
- * @param {InteractionScope} scope
47
- * @param {string} id
48
43
  * @param {Interaction} base_interaction
49
44
  */
50
45
  static add(base_interaction) {
51
46
  const key = InteractionContainer.#make_key(base_interaction.type, base_interaction.scope, base_interaction.id);
47
+ consolaInteraction.debug(`Registered interaction with key ${key}`);
52
48
  InteractionContainer.#interaction_container.set(key, base_interaction);
53
49
  }
54
50
 
55
51
  /**
56
52
  * @param {InteractionType} type
57
53
  * @param {InteractionScope} scope
58
- * @param {string} id
54
+ * @param {InteractionID} id
59
55
  * @returns {Interaction?}
60
56
  */
61
57
  static get(type, scope, id) {
62
- return InteractionContainer.#interaction_container.get(InteractionContainer.#make_key(type, scope, id));
58
+ const key = InteractionContainer.#make_key(type, scope, id);
59
+ consolaInteraction.debug(`Fetching interaction with key ${key}`);
60
+ return InteractionContainer.#interaction_container.get(key);
61
+ }
62
+
63
+ static dump() {
64
+ return Array.from(InteractionContainer.#interaction_container.entries()).map(([key, interaction]) => {
65
+ return {
66
+ key,
67
+ type: interaction.type,
68
+ scope: interaction.scope,
69
+ id: interaction.id,
70
+ function: interaction.interaction_command_function.name
71
+ }
72
+ });
63
73
  }
64
74
 
65
75
  constructor(local_config, global_config) {
66
76
  this.#local_config = local_config;
67
77
  this.#global_config = global_config;
78
+ this.Rest = new RestCommands(global_config, local_config);
68
79
  }
69
80
 
70
81
  /**
@@ -82,10 +93,10 @@ export default class InteractionContainer {
82
93
  (interaction.channel.type === ChannelType.DM) ?
83
94
  InteractionScope.DM : this.#global_config.get_scope(interaction.guild.id);
84
95
 
85
- const customId = interaction.content.slice(this.#local_config.prefix?.length)
96
+ const [customId, ...command_arguments] = interaction.content.slice(this.#local_config.prefix?.length)?.split("?")
86
97
 
87
98
  return InteractionContainer
88
- .#call(InteractionType.Message, scope, [customId], interaction)
99
+ .#call(interaction, InteractionType.Message, scope, customId, command_arguments)
89
100
  .catch(error => {
90
101
  if (error instanceof CommandError)
91
102
  return error.error_as_message(interaction)
@@ -107,8 +118,8 @@ export default class InteractionContainer {
107
118
  const scope =
108
119
  (interaction.channel.type === ChannelType.DM) ?
109
120
  InteractionScope.DM : this.#global_config.get_scope(interaction.guild.id);
110
-
111
- const customId = (interaction.commandName || interaction.customId)?.split(/\?(.+)/, 2)
121
+
122
+ const [customId, ...command_arguments] = (interaction.commandName || interaction.customId)?.split("?")
112
123
 
113
124
  var type;
114
125
  if (interaction.isChatInputCommand())
@@ -123,7 +134,7 @@ export default class InteractionContainer {
123
134
  return;
124
135
 
125
136
  return InteractionContainer
126
- .#call(type, scope, customId, interaction)
137
+ .#call(interaction, type, scope, {name: customId, subcommand: interaction.options?.getSubcommand()}, command_arguments)
127
138
  .catch(error => {
128
139
  if (error instanceof CommandError)
129
140
  return error.error_as_command(interaction)
@@ -131,25 +142,30 @@ export default class InteractionContainer {
131
142
  return interaction
132
143
  .editReply("something went wrong")
133
144
  .catch(() => interaction.reply("something went wrong"))
134
- .then(() => on_error(error))
135
- .then();
145
+ .then(
146
+ () => {consolaInteraction.error(e); return on_error(error)},
147
+ () => {consolaInteraction.error(e); return on_error(error)}
148
+ )
149
+ .then()
150
+ .catch(consolaInteraction.error);
136
151
  });
137
152
  }
138
153
 
139
154
  /**
140
155
  *
156
+ * @param {DiscordBaseInteraction} interaction
141
157
  * @param {InteractionType} type
142
158
  * @param {InteractionScope} scope
143
- * @param {InteractionID[]} customId
144
- * @param {DiscordBaseInteraction} interaction
159
+ * @param {InteractionID} customId
160
+ * @param {string[]} command_arguments
145
161
  * @returns {Promise<any>}
146
162
  * @throws {CommandError}
147
163
  */
148
- static async #call(type, scope, customId, interaction) {
149
- const interaction_command = InteractionContainer.get(type, scope, customId?.[0]);
164
+ static async #call(interaction, type, scope, customId, command_arguments = []) {
165
+ const interaction_command = InteractionContainer.get(type, scope, customId);
150
166
  if(!interaction_command)
151
- return consola.error(`No interaction found for id ${customId.join('?')} with type ${type} and scope ${scope}`);
167
+ return consolaInteraction.error(`No interaction found:\n${JSON.stringify({customId, type, scope, command_arguments: command_arguments.join(',')}, null, 2)}`);
152
168
 
153
- return await interaction_command.interaction_command_function.apply(null, [interaction, ...customId.slice(1)]);
169
+ return await interaction_command.interaction_command_function.apply(null, [interaction, ...command_arguments]);
154
170
  }
155
171
  }
@@ -1,6 +1,11 @@
1
1
  import GlobalConfig from "../Configs/GlobalConfig/GlobalConfig.js";
2
2
  import LocalConfig from "../Configs/LocalConfig.js";
3
3
  import { REST, Routes } from "discord.js";
4
+ import { InteractionScope } from "../Utils/enums.js";
5
+
6
+ /**
7
+ * @typedef {import("discord.js").SlashCommandSubcommandsOnlyBuilder} SlashCommandSubcommandsOnlyBuilder
8
+ */
4
9
 
5
10
  export default class RestCommands {
6
11
  #commands = new Map();
@@ -20,6 +25,12 @@ export default class RestCommands {
20
25
  this.#local_config = local_config;
21
26
  }
22
27
 
28
+ /**
29
+ *
30
+ * @param {SlashCommandSubcommandsOnlyBuilder[]} commands
31
+ * @param {InteractionScope} scope
32
+ * @returns {RestCommands}
33
+ */
23
34
  add(commands, scope) {
24
35
  if(!!scope)
25
36
  this.#commands.set(scope, commands);
@@ -29,6 +40,10 @@ export default class RestCommands {
29
40
  return this;
30
41
  }
31
42
 
43
+ /**
44
+ * @param {string} secret_token
45
+ * @returns {Promise<REST>}
46
+ */
32
47
  async register(secret_token){
33
48
  const rest = new REST(this.#local_config.restOptions).setToken(secret_token);
34
49
 
@@ -0,0 +1,25 @@
1
+ import { createConsola } from "consola";
2
+
3
+ const levels = {
4
+ silent: -1,
5
+ fatal: 0,
6
+ error: 1,
7
+ warn: 2,
8
+ info: 3,
9
+ debug: 4,
10
+ trace: 5,
11
+ };
12
+ const level = levels[process.env.LOG_LEVEL?.toLowerCase() || "info"] ?? 3;
13
+
14
+ const consolaInstance = createConsola({
15
+ level,
16
+ fancy: true,
17
+ formatOptions: {
18
+ date: true,
19
+ columns: 20
20
+ }
21
+ });
22
+
23
+ export const consolaConfig = consolaInstance.withTag("Config");
24
+ export const consolaInteraction = consolaInstance.withTag("Interaction");
25
+ export default consolaInstance;