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.
- package/CHANGELOG.md +264 -0
- package/THIRD_PARTY_NOTICES.md +14 -13
- package/dist/cjs/_shared/{controller.decorator-CRn9kWob.cjs → controller.decorator-XKMlcUZz.cjs} +376 -100
- package/dist/cjs/_shared/{providers-ChvMqnLd.cjs → providers-BZgJyfZS.cjs} +145 -36
- package/dist/cjs/_shared/{sharded-cooldown-store-BdAUTdYs.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
- package/dist/cjs/_shared/{startup-roots-CQo8mSP8.cjs → startup-roots-CgfPQrNl.cjs} +219 -63
- package/dist/cjs/_shared/{translate-error-DhDVbT5M.cjs → translate-error-BUAR1WYG.cjs} +10 -5
- package/dist/cjs/common/index.cjs +3 -3
- package/dist/cjs/core/index.cjs +185 -168
- package/dist/cjs/decorator/index.cjs +21 -6
- package/dist/cjs/testing/index.cjs +1594 -286
- package/dist/cli.json +1 -1
- package/dist/esm/bin/generator.js +4 -5
- package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
- package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
- package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
- package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
- package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
- package/dist/esm/bin/helper/service-generator.helper.js +4 -5
- package/dist/esm/bin/meocord.js +84 -25
- package/dist/esm/build/load-config.pre-entry.js +13 -3
- package/dist/esm/build/rsbuild-config.js +16 -12
- package/dist/esm/build/typed-externals.js +36 -0
- package/dist/esm/common/cooldown-store.js +10 -2
- package/dist/esm/common/errors.js +25 -0
- package/dist/esm/common/meocord-text.js +11 -8
- package/dist/esm/common/response/call-order.js +9 -4
- package/dist/esm/common/response/presenter.js +33 -6
- package/dist/esm/common/response/response-state.js +84 -10
- package/dist/esm/common/route.js +11 -4
- package/dist/esm/common/sharded-cooldown-store.js +1 -1
- package/dist/esm/common/translator.js +37 -6
- package/dist/esm/core/command-registration.js +93 -24
- package/dist/esm/core/command-routes.js +2 -2
- package/dist/esm/core/cooldown-runner.js +29 -8
- package/dist/esm/core/fallback.js +20 -11
- package/dist/esm/core/guard-runner.js +16 -3
- package/dist/esm/core/handler-input.js +4 -3
- package/dist/esm/core/lifecycle-hooks.js +134 -47
- package/dist/esm/core/meocord-factory.js +55 -32
- package/dist/esm/core/meocord.app.js +116 -75
- package/dist/esm/core/providers.js +7 -2
- package/dist/esm/core/running-calls.js +43 -0
- package/dist/esm/core/shard-context.js +5 -6
- package/dist/esm/core/shard-manager.js +16 -10
- package/dist/esm/core/startup-roots.js +21 -1
- package/dist/esm/core/theme-resolvers.js +4 -1
- package/dist/esm/core/theme-scope.js +3 -1
- package/dist/esm/core/theme-validation.js +17 -10
- package/dist/esm/decorator/controller.decorator.js +56 -20
- package/dist/esm/decorator/cooldown.decorator.js +17 -0
- package/dist/esm/decorator/guard.decorator.js +3 -2
- package/dist/esm/package.json.js +1 -1
- package/dist/esm/testing/catalog.js +26 -10
- package/dist/esm/testing/cooldown-store-suite.js +67 -5
- package/dist/esm/testing/discord-defaults.js +205 -30
- package/dist/esm/testing/meocord-testing-module.js +134 -28
- package/dist/esm/testing/mock-fn.js +107 -26
- package/dist/esm/testing/mock-interaction.js +1026 -172
- package/dist/esm/testing/snowflake.js +9 -2
- package/dist/esm/testing/strict-mocks.js +37 -17
- package/dist/esm/util/common.util.js +47 -19
- package/dist/esm/util/dev-runner.util.js +8 -8
- package/dist/esm/util/generator-cli.util.js +4 -5
- package/dist/esm/util/interaction.util.js +2 -2
- package/dist/esm/util/meocord-config-loader.util.js +10 -2
- package/dist/esm/util/meocord-config-validation.util.js +3 -5
- package/dist/esm/util/meocord-source-config.util.js +65 -20
- package/dist/esm/util/method-wrapper.util.js +16 -0
- package/dist/esm/util/platform.util.js +10 -10
- package/dist/esm/util/shard-process.util.js +4 -1
- package/dist/esm/util/sharding-mode.util.js +5 -7
- package/dist/esm/util/shared-state.util.js +21 -0
- package/dist/esm/util/tsconfig-paths.util.js +29 -0
- package/dist/esm/util/tsconfig.util.js +10 -10
- package/dist/esm/util/value.util.js +12 -1
- package/dist/types/common/index.d.cts +6 -6
- package/dist/types/common/index.d.ts +6 -6
- package/dist/types/core/index.d.cts +3 -2
- package/dist/types/core/index.d.ts +3 -2
- package/dist/types/decorator/index.d.cts +1 -0
- package/dist/types/decorator/index.d.ts +1 -0
- package/dist/types/{index-CdXsFgjE.d.ts → index-DKJclIi-.d.ts} +45 -21
- package/dist/types/{index-nn5p7OoX.d.cts → index-Dlf0ZZtS.d.cts} +45 -21
- package/dist/types/interface/index.d.cts +1 -1
- package/dist/types/interface/index.d.ts +1 -1
- package/dist/types/{route-BZgFoNeB.d.cts → route-Bh1U13yy.d.cts} +3 -2
- package/dist/types/{route-BZgFoNeB.d.ts → route-Bh1U13yy.d.ts} +3 -2
- package/dist/types/testing/index.d.cts +175 -53
- package/dist/types/testing/index.d.ts +175 -53
- package/dist/types/{theme-resolvers-UKr3-6Ys.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
- package/dist/types/{theme-resolvers-BcOJVzR9.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
- package/dist/types/{translator-DHMkkCpg.d.cts → translator-Bg7hwxDF.d.cts} +21 -8
- package/dist/types/{translator-BViWKtLJ.d.ts → translator-CeP4bCzp.d.ts} +21 -8
- 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
|
|
29
|
-
* selector or skin tone, joined to others by
|
|
30
|
-
|
|
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
|
|
46
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
186
|
-
* whole `/`-separated segment, so a uuid is captured whole and
|
|
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
|
-
|
|
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 ` +
|
|
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
|
-
|
|
405
|
-
const
|
|
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
|
}
|
package/dist/esm/package.json.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
24
|
-
|
|
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,
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
16
|
-
* calls at the limit where exactly one may pass
|
|
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
|
-
|
|
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:
|
|
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) {
|