meocord 4.2.0 → 4.2.1

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.
Files changed (95) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/THIRD_PARTY_NOTICES.md +14 -13
  3. package/dist/cjs/_shared/{controller.decorator-CRn9kWob.cjs → controller.decorator-XKMlcUZz.cjs} +376 -100
  4. package/dist/cjs/_shared/{providers-ChvMqnLd.cjs → providers-BZgJyfZS.cjs} +145 -36
  5. package/dist/cjs/_shared/{sharded-cooldown-store-BdAUTdYs.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
  6. package/dist/cjs/_shared/{startup-roots-CQo8mSP8.cjs → startup-roots-CgfPQrNl.cjs} +219 -63
  7. package/dist/cjs/_shared/{translate-error-DhDVbT5M.cjs → translate-error-BUAR1WYG.cjs} +10 -5
  8. package/dist/cjs/common/index.cjs +3 -3
  9. package/dist/cjs/core/index.cjs +185 -168
  10. package/dist/cjs/decorator/index.cjs +21 -6
  11. package/dist/cjs/testing/index.cjs +1594 -286
  12. package/dist/cli.json +1 -1
  13. package/dist/esm/bin/generator.js +4 -5
  14. package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
  15. package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
  16. package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
  17. package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
  18. package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
  19. package/dist/esm/bin/helper/service-generator.helper.js +4 -5
  20. package/dist/esm/bin/meocord.js +84 -25
  21. package/dist/esm/build/load-config.pre-entry.js +13 -3
  22. package/dist/esm/build/rsbuild-config.js +16 -12
  23. package/dist/esm/build/typed-externals.js +36 -0
  24. package/dist/esm/common/cooldown-store.js +10 -2
  25. package/dist/esm/common/errors.js +25 -0
  26. package/dist/esm/common/meocord-text.js +11 -8
  27. package/dist/esm/common/response/call-order.js +9 -4
  28. package/dist/esm/common/response/presenter.js +33 -6
  29. package/dist/esm/common/response/response-state.js +84 -10
  30. package/dist/esm/common/route.js +11 -4
  31. package/dist/esm/common/sharded-cooldown-store.js +1 -1
  32. package/dist/esm/common/translator.js +37 -6
  33. package/dist/esm/core/command-registration.js +93 -24
  34. package/dist/esm/core/command-routes.js +2 -2
  35. package/dist/esm/core/cooldown-runner.js +29 -8
  36. package/dist/esm/core/fallback.js +20 -11
  37. package/dist/esm/core/guard-runner.js +16 -3
  38. package/dist/esm/core/handler-input.js +4 -3
  39. package/dist/esm/core/lifecycle-hooks.js +134 -47
  40. package/dist/esm/core/meocord-factory.js +55 -32
  41. package/dist/esm/core/meocord.app.js +116 -75
  42. package/dist/esm/core/providers.js +7 -2
  43. package/dist/esm/core/running-calls.js +43 -0
  44. package/dist/esm/core/shard-context.js +5 -6
  45. package/dist/esm/core/shard-manager.js +16 -10
  46. package/dist/esm/core/startup-roots.js +21 -1
  47. package/dist/esm/core/theme-resolvers.js +4 -1
  48. package/dist/esm/core/theme-scope.js +3 -1
  49. package/dist/esm/core/theme-validation.js +17 -10
  50. package/dist/esm/decorator/controller.decorator.js +56 -20
  51. package/dist/esm/decorator/cooldown.decorator.js +17 -0
  52. package/dist/esm/decorator/guard.decorator.js +3 -2
  53. package/dist/esm/package.json.js +1 -1
  54. package/dist/esm/testing/catalog.js +26 -10
  55. package/dist/esm/testing/cooldown-store-suite.js +67 -5
  56. package/dist/esm/testing/discord-defaults.js +205 -30
  57. package/dist/esm/testing/meocord-testing-module.js +134 -28
  58. package/dist/esm/testing/mock-fn.js +107 -26
  59. package/dist/esm/testing/mock-interaction.js +1026 -172
  60. package/dist/esm/testing/snowflake.js +9 -2
  61. package/dist/esm/testing/strict-mocks.js +37 -17
  62. package/dist/esm/util/common.util.js +47 -19
  63. package/dist/esm/util/dev-runner.util.js +8 -8
  64. package/dist/esm/util/generator-cli.util.js +4 -5
  65. package/dist/esm/util/interaction.util.js +2 -2
  66. package/dist/esm/util/meocord-config-loader.util.js +10 -2
  67. package/dist/esm/util/meocord-config-validation.util.js +3 -5
  68. package/dist/esm/util/meocord-source-config.util.js +65 -20
  69. package/dist/esm/util/method-wrapper.util.js +16 -0
  70. package/dist/esm/util/platform.util.js +10 -10
  71. package/dist/esm/util/shard-process.util.js +4 -1
  72. package/dist/esm/util/sharding-mode.util.js +5 -7
  73. package/dist/esm/util/shared-state.util.js +21 -0
  74. package/dist/esm/util/tsconfig-paths.util.js +29 -0
  75. package/dist/esm/util/tsconfig.util.js +10 -10
  76. package/dist/esm/util/value.util.js +12 -1
  77. package/dist/types/common/index.d.cts +6 -6
  78. package/dist/types/common/index.d.ts +6 -6
  79. package/dist/types/core/index.d.cts +3 -2
  80. package/dist/types/core/index.d.ts +3 -2
  81. package/dist/types/decorator/index.d.cts +1 -0
  82. package/dist/types/decorator/index.d.ts +1 -0
  83. package/dist/types/{index-CdXsFgjE.d.ts → index-DKJclIi-.d.ts} +45 -21
  84. package/dist/types/{index-nn5p7OoX.d.cts → index-Dlf0ZZtS.d.cts} +45 -21
  85. package/dist/types/interface/index.d.cts +1 -1
  86. package/dist/types/interface/index.d.ts +1 -1
  87. package/dist/types/{route-BZgFoNeB.d.cts → route-Bh1U13yy.d.cts} +3 -2
  88. package/dist/types/{route-BZgFoNeB.d.ts → route-Bh1U13yy.d.ts} +3 -2
  89. package/dist/types/testing/index.d.cts +175 -53
  90. package/dist/types/testing/index.d.ts +175 -53
  91. package/dist/types/{theme-resolvers-UKr3-6Ys.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
  92. package/dist/types/{theme-resolvers-BcOJVzR9.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
  93. package/dist/types/{translator-DHMkkCpg.d.cts → translator-Bg7hwxDF.d.cts} +21 -8
  94. package/dist/types/{translator-BViWKtLJ.d.ts → translator-CeP4bCzp.d.ts} +21 -8
  95. package/package.json +2 -2
@@ -1,5 +1,6 @@
1
1
  import { ButtonStyle, Colors } from 'discord.js';
2
2
  import { refuse } from '../util/refusal.util.js';
3
+ import { escapeForLog, quoteForLog } from '../util/user-text.util.js';
3
4
 
4
5
  /**
5
6
  * The role names MeoCord keeps for roles it may add to `colors`, `emojis` and `buttons`: the runtime copy of
@@ -25,9 +26,10 @@ const EMOJI = 'a unicode emoji, or a custom one written <:name:id> or <a:name:id
25
26
  const BUTTON = 'ButtonStyle.Primary, Secondary, Success or Danger';
26
27
  /** A custom emoji as Discord writes one in text: its name, 2 to 32 word characters, and its id. */ const CUSTOM_EMOJI = /^<a?:\w{2,32}:\d{17,20}>$/;
27
28
  /**
28
- * One unicode emoji: a flag of two regional indicators, a keycap, or a pictograph with an optional presentation
29
- * selector or skin tone, joined to others by zero-width joiners into one sequence.
30
- */ const UNICODE_EMOJI = /^(?:\p{Regional_Indicator}{2}|[#*0-9]️?⃣|\p{Extended_Pictographic}(?:️|\p{Emoji_Modifier})?(?:‍\p{Extended_Pictographic}(?:️|\p{Emoji_Modifier})?)*)$/u;
29
+ * One unicode emoji: a flag of two regional indicators, a subdivision flag (a black flag, tag characters and a cancel
30
+ * tag, as England's), a keycap, or a pictograph with an optional presentation selector or skin tone, joined to others by
31
+ * zero-width joiners into one sequence.
32
+ */ const UNICODE_EMOJI = /^(?:\p{Regional_Indicator}{2}|\u{1F3F4}[\u{E0020}-\u{E007E}]+\u{E007F}|[#*0-9]️?⃣|\p{Extended_Pictographic}(?:️|\p{Emoji_Modifier})?(?:‍\p{Extended_Pictographic}(?:️|\p{Emoji_Modifier})?)*)$/u;
31
33
  const byte = (value)=>Number.isInteger(value) && value >= 0 && value <= 255;
32
34
  /** Whether discord.js resolves the value to a colour Discord takes. Stricter on tuples, whose channels it only sums. */ function isColour(value) {
33
35
  if (typeof value === 'string') return /^#?[\da-f]{6}$/i.test(value) || value === 'Random' || value === 'Default' || Object.hasOwn(Colors, value);
@@ -42,8 +44,9 @@ const THEMED_BUTTON_STYLES = [
42
44
  ButtonStyle.Danger
43
45
  ];
44
46
  const isButtonStyle = (value)=>THEMED_BUTTON_STYLES.includes(value);
45
- const describe = (value)=>typeof value === 'string' ? `'${value}'` : Array.isArray(value) ? `[${value.map((item)=>typeof item === 'string' ? `'${item}'` : String(item)).join(', ')}]` : value === null ? 'null' : typeof value === 'object' ? 'an object' : String(value);
46
- const describeGroup = (value)=>Array.isArray(value) ? 'an array' : describe(value);
47
+ const asWritten = (text)=>`'${text}'`;
48
+ const describe = (value, quote = asWritten)=>typeof value === 'string' ? quote(value) : Array.isArray(value) ? `[${value.map((item)=>typeof item === 'string' ? quote(item) : String(item)).join(', ')}]` : value === null ? 'null' : typeof value === 'object' ? 'an object' : String(value);
49
+ const describeGroup = (value, quote = asWritten)=>Array.isArray(value) ? 'an array' : describe(value, quote);
47
50
  /** Whether a value is a plain object, as an object literal or `JSON.parse` makes: one a theme merges key by key. */ const isPlainObject = (value)=>{
48
51
  const prototype = Object.getPrototypeOf(value);
49
52
  return prototype === Object.prototype || prototype === null;
@@ -75,11 +78,15 @@ const PLAIN = "give a plain object, such as { ...value }, or a row's toObject(),
75
78
  * roles an app added, a colour discord.js cannot resolve, an emoji Discord would refuse, a button style other than
76
79
  * Discord's four coloured ones, or a reserved role name. A group of the app's own is its to check.
77
80
  * @param where - Where it was set, such as `themeFor.guild for guild 123`, to begin each line with.
78
- */ function themeProblems(theme, where) {
81
+ * @param options.forLog - Shows values and role names as a log line shows text from outside the code, for a theme a
82
+ * `themeFor` resolver returned: escaped, and a value quoted and cut short.
83
+ */ function themeProblems(theme, where, { forLog = false } = {}) {
79
84
  const at = where ? `${where}: ` : '';
85
+ const quote = forLog ? quoteForLog : asWritten;
86
+ const name = forLog ? escapeForLog : (role)=>role;
80
87
  if (typeof theme !== 'object' || theme === null || Array.isArray(theme)) {
81
88
  return [
82
- `${at}theme must be an object of groups (got ${describeGroup(theme)})`
89
+ `${at}theme must be an object of groups (got ${describeGroup(theme, quote)})`
83
90
  ];
84
91
  }
85
92
  if (!isPlainObject(theme)) return [
@@ -91,7 +98,7 @@ const PLAIN = "give a plain object, such as { ...value }, or a row's toObject(),
91
98
  const roles = theme[group];
92
99
  if (roles === undefined) continue;
93
100
  if (typeof roles !== 'object' || roles === null || Array.isArray(roles)) {
94
- problems.push(`${at}theme.${group} must be an object of roles (got ${describeGroup(roles)})`);
101
+ problems.push(`${at}theme.${group} must be an object of roles (got ${describeGroup(roles, quote)})`);
95
102
  continue;
96
103
  }
97
104
  if (!isPlainObject(roles)) {
@@ -99,8 +106,8 @@ const PLAIN = "give a plain object, such as { ...value }, or a row's toObject(),
99
106
  continue;
100
107
  }
101
108
  for (const [role, value] of Object.entries(roles)){
102
- if (reserved.has(role)) problems.push(`${at}theme.${group}.${role}: MeoCord reserves the role name ${role} for a role it may add; rename yours`);
103
- else if (value !== undefined && !check(value)) problems.push(`${at}theme.${group}.${role}: ${describe(value)} is not ${what}: give ${instead}`);
109
+ if (reserved.has(role)) problems.push(`${at}theme.${group}.${name(role)}: MeoCord reserves the role name ${name(role)} for a role it may add; rename yours`);
110
+ else if (value !== undefined && !check(value)) problems.push(`${at}theme.${group}.${name(role)}: ${describe(value, quote)} is not ${what}: give ${instead}`);
104
111
  }
105
112
  }
106
113
  return problems;
@@ -2,7 +2,8 @@ import 'reflect-metadata';
2
2
  import { UserContextMenuCommandInteraction, ApplicationCommandType, MessageContextMenuCommandInteraction } from 'discord.js';
3
3
  import { CommandType } from '../enum/controller.enum.js';
4
4
  import { MetadataKey } from '../enum/metadata-key.enum.js';
5
- import { matchesCommandType, interactionClassName, isCustomIdRouted } from '../util/interaction.util.js';
5
+ import { isCustomIdRouted, matchesCommandType, interactionClassName } from '../util/interaction.util.js';
6
+ import { wrapsMethod } from '../util/method-wrapper.util.js';
6
7
  import { warnDeprecatedBehaviour } from '../common/deprecation.js';
7
8
  import { Logger } from '../common/logger.js';
8
9
  import { routeSpecificity } from '../core/route-specificity.js';
@@ -79,9 +80,9 @@ const autocompleteRoute = (method, commandPath, optionName)=>({
79
80
  // An entry the class takes from its base, for a method it re-declares
80
81
  const inheritedFor = (inherited, method)=>(entry)=>inherited.includes(entry) && redeclared.has(method(entry));
81
82
  const commands = ownCommandMap(prototype);
82
- const baseCommands = Reflect.getMetadata(META.commands, base) ?? {};
83
+ const baseCommands = Reflect.getMetadata(META.commands, base) ?? Object.create(null);
83
84
  for (const [name, metas] of Object.entries(commands)){
84
- const kept = metas.filter((meta)=>!inheritedFor(baseCommands[name] ?? [], (entry)=>entry.methodName)(meta));
85
+ const kept = metas.filter((meta)=>!inheritedFor(commandsNamed(baseCommands, name), (entry)=>entry.methodName)(meta));
85
86
  if (kept.length > 0) commands[name] = kept;
86
87
  else delete commands[name];
87
88
  }
@@ -105,16 +106,21 @@ const autocompleteRoute = (method, commandPath, optionName)=>({
105
106
  Reflect.defineMetadata(key, ownHandlerList(key, prototype).filter((entry)=>!inheritedFor(inherited, method)(entry)), prototype);
106
107
  }
107
108
  }
108
- /** The class's own command map, started from a copy of the inherited one, for the same reason. */ function ownCommandMap(target) {
109
+ /**
110
+ * The class's own command map, started from a copy of the inherited one, for the same reason. Keyed by command name
111
+ * without a prototype, so a command named like an inherited key, such as `constructor`, is a key like any other.
112
+ */ function ownCommandMap(target) {
109
113
  const own = Reflect.getOwnMetadata(META.commands, target);
110
114
  if (own) return own;
111
115
  const inherited = Reflect.getMetadata(META.commands, target) ?? {};
112
- return Object.fromEntries(Object.entries(inherited).map(([name, metas])=>[
113
- name,
114
- [
115
- ...metas
116
- ]
117
- ]));
116
+ const copy = Object.create(null);
117
+ for (const [name, metas] of Object.entries(inherited))copy[name] = [
118
+ ...metas
119
+ ];
120
+ return copy;
121
+ }
122
+ /** The handlers a command map holds for `name`: its own entry only, never one `Object.prototype` holds. */ function commandsNamed(map, name) {
123
+ return map && Object.hasOwn(map, name) ? map[name] : [];
118
124
  }
119
125
  function MessageHandler(pattern, options = {}) {
120
126
  return declaring(function(target, propertyKey) {
@@ -179,12 +185,24 @@ function ReactionHandler(emojiOrSettings, settings = {}) {
179
185
  }
180
186
  // `{name}`, or `{name:type}` with the type read as far as the brace, so a type no segment can hold is named
181
187
  const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
188
+ // The whole of a brace pair that is a param, as PLACEHOLDER_PATTERN reads one
189
+ const WHOLE_PLACEHOLDER = new RegExp(`^${PLACEHOLDER_PATTERN.source}$`);
190
+ /**
191
+ * Why a pattern's brace pairs that aren't params are matched as literal text, or undefined when it has none: a
192
+ * param's name is ASCII letters, digits and _, so `{café}` is text.
193
+ */ function literalBraces(pattern) {
194
+ const braces = (pattern.match(/\{[^{}]*}/g) ?? []).filter((brace)=>!WHOLE_PLACEHOLDER.test(brace));
195
+ if (braces.length === 0) return undefined;
196
+ const one = braces.length === 1;
197
+ return `${braces.join(', ')} ${one ? "isn't a param" : "aren't params"}, since a param's name is ASCII letters, digits and _; ` + `${one ? 'it is' : 'they are'} matched as literal text.`;
198
+ }
182
199
  /** The character a parameter will not cross, so one pattern segment maps to one value. */ const PARAM_SEPARATOR = '/';
200
+ /** The longest customId Discord accepts; the shortest is one character. */ const MAX_CUSTOM_ID_LENGTH = 100;
183
201
  /** Escapes a literal stretch of a pattern so only placeholders stay meaningful. */ const escapeLiteral = (literal)=>literal.replace(/[/\\^$*+?.()|[\]{}]/g, '\\$&');
184
202
  /**
185
- * Compiles a pattern into a regex, its parameter names, their types and its 4.1 specificity. A `{name}` takes a
186
- * whole `/`-separated segment, so a uuid is captured whole and `profile/{uuid}` never overlaps
187
- * `profile/{uuid}/{id}`; a param that shares a segment with literal text is refused.
203
+ * Compiles a pattern into a regex, its parameter names, their types, its 4.1 specificity and the length of the
204
+ * shortest customId it matches. A `{name}` takes a whole `/`-separated segment, so a uuid is captured whole and
205
+ * `profile/{uuid}` never overlaps `profile/{uuid}/{id}`; a param that shares a segment with literal text is refused.
188
206
  */ function createRegexFromPattern(pattern) {
189
207
  const params = [];
190
208
  // By the param's own name, so one named like an inherited key, such as `__proto__`, keeps its type
@@ -205,6 +223,13 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
205
223
  if (literal !== '' && !literal.endsWith(PARAM_SEPARATOR) || after !== undefined && after !== PARAM_SEPARATOR) {
206
224
  throw refuse(new Error(`Invalid pattern "${pattern}": {${param}} must occupy a whole segment, so it has to be ` + `preceded and followed by "${PARAM_SEPARATOR}" or by the ends of the pattern. ` + `Write "a${PARAM_SEPARATOR}{${param}}" rather than "a-{${param}}".`));
207
225
  }
226
+ // A regex group's name, which the engine would refuse with the compiled regex in place of the pattern
227
+ if (params.includes(param)) {
228
+ throw refuse(new Error(`Invalid pattern "${pattern}": {${param}} appears twice; give each param its own name.`));
229
+ }
230
+ if (/^\d/.test(param)) {
231
+ throw refuse(new Error(`Invalid pattern "${pattern}": {${param}} starts with a digit; start a param's name with a letter or _.`));
232
+ }
208
233
  if (type !== undefined) {
209
234
  if (!isSegmentType(type)) throw refuse(new Error(`Invalid pattern "${pattern}": ${segmentTypeProblem(param, type)}`));
210
235
  if (choicesOf(type)?.includes('')) {
@@ -228,11 +253,13 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
228
253
  params: params.length,
229
254
  typed: Object.values(types).reduce((sum, type)=>sum + (choicesOf(type) ? 4 : type === 'bool' ? 3 : type === 'int' ? 2 : 1), 0)
230
255
  });
256
+ // Each param takes at least one character
231
257
  return {
232
258
  regex,
233
259
  params,
234
260
  types: lookupTable(types),
235
- specificity
261
+ specificity,
262
+ shortest: literalLength + params.length
236
263
  };
237
264
  }
238
265
  /** Why a `{name:type}` cannot type a customId segment, which holds text the bot wrote, with no message to read. */ function segmentTypeProblem(param, type) {
@@ -302,7 +329,6 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
302
329
  */ function Command(name, builderOrType) {
303
330
  const commandName = typeof name === 'string' ? name : name.pattern;
304
331
  return declaring(function(target, propertyKey, _descriptor) {
305
- var _commands, _commandName;
306
332
  refuseOnClass('@Command', target, propertyKey);
307
333
  const originalMethod = _descriptor.value;
308
334
  if (!originalMethod) {
@@ -310,7 +336,7 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
310
336
  }
311
337
  // Wrap original method for interaction type validation
312
338
  const declaredAs = typeof builderOrType === 'function' ? builderOrType.name : `CommandType.${builderOrType}`;
313
- _descriptor.value = function(interaction, params) {
339
+ const checked = function(interaction, params) {
314
340
  if (!matchesCommandType(commandType, interaction)) {
315
341
  throw new Error(`${target.constructor.name}.${propertyKey}: @Command('${commandName}', ${declaredAs}) takes ` + `${interactionClassName(commandType)}${givenInstead(interaction)}.`);
316
342
  }
@@ -319,6 +345,7 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
319
345
  params
320
346
  ]);
321
347
  };
348
+ _descriptor.value = wrapsMethod(checked, originalMethod);
322
349
  // This class's own map, inherited routes included
323
350
  const commands = ownCommandMap(target);
324
351
  let builderInstance;
@@ -354,7 +381,7 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
354
381
  }));
355
382
  }
356
383
  // discord.js builders validate as they are set, and their errors name neither the command nor the field.
357
- throw refuse(new Error(`${where}: ${builderOrType.name} could not build "${commandName}": ${detail}. Check its names, descriptions and ` + `localizations, which Discord limits to 32 and 100 characters.`, {
384
+ throw refuse(new Error(`${where}: ${builderOrType.name} could not build "${commandName}": ${detail}. Check its names, descriptions and ` + 'localizations, which Discord limits to 32 characters for a name and 100 for a description. A name is letters, ' + 'numbers, `-` and `_`, lowercase where the script has case, and without spaces, in every locale.', {
358
385
  cause: error
359
386
  }));
360
387
  }
@@ -374,6 +401,9 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
374
401
  commandType = builderOrType;
375
402
  }
376
403
  if (isCustomIdRouted(commandType)) {
404
+ // A route warned as it was made
405
+ const braces = typeof name === 'string' ? literalBraces(commandName) : undefined;
406
+ if (braces) logger.warn(`${target.constructor.name}.${propertyKey}: in the pattern "${commandName}", ${braces}`);
377
407
  let pattern;
378
408
  try {
379
409
  pattern = createRegexFromPattern(commandName);
@@ -385,6 +415,11 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
385
415
  regex = pattern.regex;
386
416
  dynamicParams = pattern.params;
387
417
  specificity = pattern.specificity;
418
+ // Warned rather than refused, so a bot that carries such a handler still starts
419
+ if (pattern.shortest === 0 || pattern.shortest > MAX_CUSTOM_ID_LENGTH) {
420
+ const length = pattern.shortest === 0 ? 'the pattern is empty' : `its shortest is ${pattern.shortest} characters`;
421
+ logger.warn(`${target.constructor.name}.${propertyKey}: the pattern "${commandName}" matches no customId: Discord's customIds ` + `are 1 to ${MAX_CUSTOM_ID_LENGTH} characters, and ${length}, so the handler never runs.`);
422
+ }
388
423
  }
389
424
  const declared = {
390
425
  methodName: propertyKey,
@@ -401,8 +436,9 @@ const PLACEHOLDER_PATTERN = /\{(\w+)(?::([^}/]*))?}/g;
401
436
  }
402
437
  };
403
438
  // One the class inherits for this method and route is replaced where it stands, so this one's options apply
404
- const metas = (_commands = commands)[_commandName = commandName] ?? (_commands[_commandName] = []);
405
- const inherited = getCommandMap(Object.getPrototypeOf(target))?.[commandName] ?? [];
439
+ if (!Object.hasOwn(commands, commandName)) commands[commandName] = [];
440
+ const metas = commands[commandName];
441
+ const inherited = commandsNamed(getCommandMap(Object.getPrototypeOf(target)), commandName);
406
442
  const replaced = metas.findIndex((meta)=>inherited.includes(meta) && meta.methodName === propertyKey && meta.type === commandType);
407
443
  if (replaced === -1) metas.push(declared);
408
444
  else metas[replaced] = declared;
@@ -572,4 +608,4 @@ const narrowness = (type)=>choicesOf(type) ? 6 : NARROWNESS[type];
572
608
  return values.every((value)=>value !== undefined) ? values.join(PARAM_SEPARATOR) : undefined;
573
609
  }
574
610
 
575
- export { Autocomplete, Command, MessageHandler, PARAM_SEPARATOR, ReactionHandler, createRegexFromPattern, dropInheritedRoutes, getAutocompleteHandlers, getCommandMap, getDeclaredRoutes, getHandlerRoutes, getMessageHandlers, getReactionHandlers, matchesEmoji, ownHandlerList, paramNarrowness, patternShape, sharedCustomId };
611
+ export { Autocomplete, Command, MAX_CUSTOM_ID_LENGTH, MessageHandler, PARAM_SEPARATOR, ReactionHandler, commandsNamed, createRegexFromPattern, dropInheritedRoutes, getAutocompleteHandlers, getCommandMap, getDeclaredRoutes, getHandlerRoutes, getMessageHandlers, getReactionHandlers, literalBraces, matchesEmoji, ownHandlerList, paramNarrowness, patternShape, sharedCustomId };
@@ -1,5 +1,7 @@
1
1
  import 'reflect-metadata';
2
+ import { Logger } from '../common/logger.js';
2
3
  import { declaring, refuse, decoratedName } from '../util/refusal.util.js';
4
+ import { describeValue } from '../util/value.util.js';
3
5
  import { META } from '../util/metadata-keys.js';
4
6
 
5
7
  function Cooldown(options) {
@@ -14,6 +16,7 @@ function Cooldown(options) {
14
16
  // Checked where it applies, so the refusal names the handler or controller
15
17
  const problem = cooldownProblem(seconds, uses, per, by);
16
18
  if (problem) throw refuse(new Error(`${decoratedName(target, propertyKey)}: ${problem}`));
19
+ for (const warning of cooldownWarnings(options))logger.warn(`${decoratedName(target, propertyKey)}: ${warning}`);
17
20
  // Decorators apply bottom-up; prepending keeps them in the order they read.
18
21
  if (propertyKey === undefined) {
19
22
  const existing = Reflect.getOwnMetadata(META.classCooldowns, target) ?? [];
@@ -34,6 +37,20 @@ function Cooldown(options) {
34
37
  * The longest window, in seconds: half the furthest time a `Date` holds, 8.64e15 ms, so the moment a wait ends, now
35
38
  * plus the window, is a real date for a refusal's `retryAt` and the time it shows.
36
39
  */ const MAX_SECONDS = 4320000000000;
40
+ const logger = new Logger('Cooldown');
41
+ /**
42
+ * What a cooldown's options get wrong that the types refuse, yet still counts: a `bypass` that is not a function, which
43
+ * fails every call, and a `seconds` that is not a number, read as one. A falsy `bypass` means none.
44
+ */ function cooldownWarnings({ seconds, bypass }) {
45
+ const warnings = [];
46
+ if (bypass && typeof bypass !== 'function') {
47
+ warnings.push(`@Cooldown takes bypass as a function of the call, returning whether the call skips the cooldown, not ${describeValue(bypass)}; ` + 'every call it counts fails with it. In the next major version (5.0) it is refused.');
48
+ }
49
+ if (typeof seconds !== 'number') {
50
+ warnings.push(`@Cooldown takes seconds as a number, not ${describeValue(seconds)}, which it counts as a window of ` + `${Math.round(seconds * 1000)} ms. In the next major version (5.0) it is refused.`);
51
+ }
52
+ return warnings;
53
+ }
37
54
  /** Why a cooldown's options cannot be counted, or undefined when they can. */ function cooldownProblem(seconds, uses, per, by) {
38
55
  if (!(seconds >= 0.001 && seconds <= MAX_SECONDS)) return `@Cooldown needs a number of seconds from 0.001 to ${MAX_SECONDS}, not ${seconds}.`;
39
56
  if (!Number.isInteger(uses) || uses < 1) return `@Cooldown needs a whole number of uses of at least 1, not ${uses}.`;
@@ -1,6 +1,7 @@
1
1
  import 'reflect-metadata';
2
2
  import { handlerMethods, classLevelGuards, consumeDispatchMark, runDirectCall, declaringPrototype, classChain } from '../core/guard-runner.js';
3
3
  import { makeInjectable } from '../util/injectable.util.js';
4
+ import { wrapsMethod } from '../util/method-wrapper.util.js';
4
5
  import { defineStageTypes, assertStageEntries } from '../core/stage-scope.js';
5
6
  import { META } from '../util/metadata-keys.js';
6
7
  import { declaring, deprecatedOnMethod } from '../util/refusal.util.js';
@@ -34,10 +35,10 @@ import { declaring, deprecatedOnMethod } from '../util/refusal.util.js';
34
35
  * which has already run the guards, passes through every wrapper.
35
36
  */ function applyGuards(descriptor, prototype, propertyKey) {
36
37
  const originalMethod = descriptor.value;
37
- descriptor.value = async function(...args) {
38
+ descriptor.value = wrapsMethod(async function(...args) {
38
39
  if (consumeDispatchMark(args[0], this, propertyKey)) return originalMethod.apply(this, args);
39
40
  return runDirectCall(this, propertyKey, args, ()=>originalMethod.apply(this, args));
40
- };
41
+ }, originalMethod);
41
42
  const wrappers = Reflect.getOwnMetadata(META.guardWrappers, prototype, propertyKey) ?? 0;
42
43
  Reflect.defineMetadata(META.guardWrappers, wrappers + 1, prototype, propertyKey);
43
44
  }
@@ -1,4 +1,4 @@
1
- var version = "4.2.0";
1
+ var version = "4.2.1";
2
2
  var engines = {
3
3
  node: ">=22.13"
4
4
  };
@@ -1,7 +1,15 @@
1
1
  import { CATALOGS, placeholderNames } from '../common/translator.js';
2
2
  import { MEOCORD_MESSAGES } from '../common/meocord-messages.js';
3
3
 
4
- const isPlural = (value)=>typeof value === 'object' && value !== null && typeof value.other === 'string';
4
+ const PLURAL_CATEGORIES = new Set([
5
+ 'zero',
6
+ 'one',
7
+ 'two',
8
+ 'few',
9
+ 'many',
10
+ 'other'
11
+ ]);
12
+ /** A plural: an object whose every key is a plural category, as the translator's types read one; any other is a group. */ const isPlural = (value)=>typeof value === 'object' && value !== null && Object.keys(value).length > 0 && Object.keys(value).every((key)=>PLURAL_CATEGORIES.has(key));
5
13
  /** Every message key of a catalog, and whether it is a plural. */ function leaves(catalog, prefix = '') {
6
14
  return Object.entries(catalog).flatMap(([name, value])=>{
7
15
  const key = `${prefix}${name}`;
@@ -20,8 +28,9 @@ const isPlural = (value)=>typeof value === 'object' && value !== null && typeof
20
28
  return leaves(value, `${key}.`);
21
29
  });
22
30
  }
23
- function at(catalog, key) {
24
- return key.split('.').reduce((current, part)=>current?.[part], catalog);
31
+ /** A translation's text, which an empty string isn't: the translator reads `''` as missing, as tools export one untranslated. */ const isText = (value)=>typeof value === 'string' && value !== '';
32
+ /** The value at a key's path, through own properties only, so `constructor` or `toString` is not read off a prototype. */ function at(catalog, key) {
33
+ return key.split('.').reduce((current, part)=>typeof current === 'object' && current !== null && Object.hasOwn(current, part) ? current[part] : undefined, catalog);
25
34
  }
26
35
  const MEOCORD_KEYS = new Set(leaves(MEOCORD_MESSAGES).map(({ key })=>key));
27
36
  const isMeoCordKey = (key)=>key.startsWith('meocord.');
@@ -44,9 +53,11 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
44
53
  *
45
54
  * Use it in a test for a team that wants no fallback in production. It reports the messages a locale lacks, the
46
55
  * messages the default catalog doesn't have, the plural forms a language needs but a plural lacks, such as `few` for
47
- * Russian, and each `{param}` a translation uses that the message it translates doesn't take. It reads the catalogs'
48
- * own strings, so it checks the params of a catalog from a plain variable or a JSON file too, which the compiler
49
- * can't.
56
+ * Russian, a translation of another shape than the default's, a text for a plural or a plural for a text, and each
57
+ * `{param}` a translation uses that the message it translates doesn't take. An empty translation, `''`, counts as
58
+ * lacking, as the translator reads it as missing. It reads the catalogs' own strings, so it checks the params of a
59
+ * catalog from a plain variable or a JSON file too, which the compiler can't. An object counts as a plural only when
60
+ * its every key is a plural category, so a group with an `other` key is a group.
50
61
  *
51
62
  * @remarks
52
63
  * MeoCord's own texts, a catalog's `meocord` group, fall back to English by design, so only a key MeoCord lacks, and
@@ -93,12 +104,17 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
93
104
  const categories = new Intl.PluralRules(locale).resolvedOptions().pluralCategories;
94
105
  for (const { key, plural } of reference){
95
106
  const message = at(catalog, key);
96
- if (message === undefined) {
107
+ if (message === undefined || message === '') {
97
108
  problems.push(`missing ${key}`);
98
109
  continue;
99
110
  }
100
- if (plural && isPlural(message)) {
101
- const missing = categories.filter((category)=>typeof message[category] !== 'string');
111
+ // A translation of another shape than the default's can't be read as it: a text for a plural, or the reverse
112
+ if (plural !== isPlural(message) || !plural && typeof message !== 'string') {
113
+ problems.push(`${key} should be a ${plural ? 'plural' : 'text'}, as the default is`);
114
+ continue;
115
+ }
116
+ if (plural) {
117
+ const missing = categories.filter((category)=>!isText(message[category]));
102
118
  if (missing.length > 0) problems.push(`${key} lacks ${missing.join(', ')}`);
103
119
  }
104
120
  const original = at(defaultCatalog, key);
@@ -106,7 +122,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
106
122
  for (const name of stray)problems.push(`${key} takes no {${name}}; the default is "${shown(original)}"`);
107
123
  }
108
124
  if (options.meocord && !locale.startsWith('en-')) {
109
- for (const key of MEOCORD_KEYS)if (at(catalog, key) === undefined) problems.push(`missing ${key}`);
125
+ for (const key of MEOCORD_KEYS)if (at(catalog, key) === undefined || at(catalog, key) === '') problems.push(`missing ${key}`);
110
126
  }
111
127
  for (const { key } of leaves(catalog)){
112
128
  if (isMeoCordKey(key)) {
@@ -12,8 +12,9 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
12
12
  *
13
13
  * @remarks
14
14
  * The cases cover what a shared store most often gets wrong: a sliding window rather than fixed buckets,
15
- * `retryAfterMs` from the oldest call still in the window, calls in the same millisecond kept apart, and concurrent
16
- * calls at the limit where exactly one may pass. They use real time, with windows short enough that the suite takes
15
+ * `retryAfterMs` until a use frees up, from the oldest of the newest `uses` calls in the window, calls in the same
16
+ * millisecond kept apart, concurrent calls at the limit where exactly one may pass, a batch refusal that names the
17
+ * longest wait, and a release that frees only the call it gave back. They use real time, with windows short enough that the suite takes
17
18
  * a few seconds and margins wide enough that a correct store passes when timers fire late, as on a busy CI runner,
18
19
  * and each case counts under keys of its own. Each window is a whole number of milliseconds, as
19
20
  * `@Cooldown` gives a store: it rounds its `seconds` to the millisecond.
@@ -88,6 +89,30 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
88
89
  expect(retryAfterMs).toBeLessThanOrEqual(limit.windowMs - (secondSent - firstDone) + CLOCK_SLACK_MS);
89
90
  expect(retryAfterMs).toBeGreaterThanOrEqual(limit.windowMs - (thirdDone - firstSent) - CLOCK_SLACK_MS);
90
91
  });
92
+ // @Cooldown keeps the calls counted so far when a limit is lowered, so a key can hold more calls than `uses`
93
+ test('counts retryAfterMs from the oldest of the newest `uses` calls when a lowered limit leaves more in the window', async (store)=>{
94
+ const windowMs = 2000;
95
+ for(let call = 0; call < 3; call += 1)await store.consume(key('lowered'), {
96
+ uses: 5,
97
+ windowMs
98
+ });
99
+ await sleep(1000);
100
+ const newerSent = Date.now();
101
+ for(let call = 0; call < 2; call += 1)await store.consume(key('lowered'), {
102
+ uses: 5,
103
+ windowMs
104
+ });
105
+ const { allowed, retryAfterMs } = await store.consume(key('lowered'), {
106
+ uses: 2,
107
+ windowMs
108
+ });
109
+ const refusedDone = Date.now();
110
+ expect(allowed).toBe(false);
111
+ // A use frees up when the older of the two newest calls leaves the window; counted from the oldest call, it would
112
+ // be a second sooner
113
+ expect(retryAfterMs).toBeGreaterThanOrEqual(windowMs - (refusedDone - newerSent) - CLOCK_SLACK_MS);
114
+ expect(retryAfterMs).toBeLessThanOrEqual(windowMs + CLOCK_SLACK_MS);
115
+ });
91
116
  test('counts each key on its own', async (store)=>{
92
117
  const limit = {
93
118
  uses: 1,
@@ -149,6 +174,41 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
149
174
  expect(refused.retryAfterMs).toBeGreaterThan(short.windowMs);
150
175
  expect(refused.retryAfterMs).toBeLessThanOrEqual(long.windowMs);
151
176
  });
177
+ // A refusal names the wait until every cooldown of the batch allows the call; the default consumeMany stops at the first
178
+ test('names the longest wait when several cooldowns refuse a batch, or with the default consumeMany, the first', async (store)=>{
179
+ const short = {
180
+ uses: 1,
181
+ windowMs: 1000
182
+ };
183
+ const long = {
184
+ uses: 1,
185
+ windowMs: 2000
186
+ };
187
+ const batch = [
188
+ {
189
+ key: key('both-short'),
190
+ limit: short
191
+ },
192
+ {
193
+ key: key('both-long'),
194
+ limit: long
195
+ }
196
+ ];
197
+ await store.consume(batch[0].key, short);
198
+ await store.consume(batch[1].key, long);
199
+ const refused = await store.consumeMany(batch);
200
+ expect(refused.allowed).toBe(false);
201
+ const [blocked, limit] = overridesConsumeMany(store) ? [
202
+ 1,
203
+ long
204
+ ] : [
205
+ 0,
206
+ short
207
+ ];
208
+ expect(refused.blocked).toBe(blocked);
209
+ expect(refused.retryAfterMs).toBeGreaterThan(limit.windowMs - 1000);
210
+ expect(refused.retryAfterMs).toBeLessThanOrEqual(limit.windowMs);
211
+ });
152
212
  // The default consumeMany counts in order and stops at the first refusal, so the entries before it count
153
213
  test('records nothing when one cooldown of a batch refuses it, or with the default consumeMany, counts those before it', async (store)=>{
154
214
  const limit = {
@@ -191,10 +251,11 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
191
251
  }, ()=>store.consumeMany(batch)));
192
252
  expect(verdicts.filter((verdict)=>verdict.allowed).length).toBe(1);
193
253
  });
194
- // A store without release keeps a counted call counted, as the default consumeMany does
195
- test('releases a call it counted when the verdict gives release, or keeps it counted without one', async (store)=>{
254
+ // A store without release keeps a counted call counted, as the default consumeMany does. Two uses tell a release of
255
+ // the one call apart from one that frees the key's other calls too
256
+ test('releases only the call it counted when the verdict gives release, or keeps it counted without one', async (store)=>{
196
257
  const limit = {
197
- uses: 1,
258
+ uses: 2,
198
259
  windowMs: 2000
199
260
  };
200
261
  const entries = [
@@ -207,6 +268,7 @@ const sleep = (ms)=>new Promise((resolve)=>setTimeout(resolve, ms));
207
268
  limit
208
269
  }
209
270
  ];
271
+ expect((await store.consumeMany(entries)).allowed).toBe(true);
210
272
  const counted = await store.consumeMany(entries);
211
273
  expect(counted.allowed).toBe(true);
212
274
  if (!counted.release) {