meocord 4.1.2 → 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 +362 -0
- package/THIRD_PARTY_NOTICES.md +14 -13
- package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-XKMlcUZz.cjs} +669 -185
- package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-BZgJyfZS.cjs} +218 -75
- package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
- package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CgfPQrNl.cjs} +374 -132
- package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-BUAR1WYG.cjs} +21 -7
- package/dist/cjs/common/index.cjs +4 -4
- package/dist/cjs/core/index.cjs +365 -310
- package/dist/cjs/decorator/index.cjs +85 -61
- package/dist/cjs/testing/index.cjs +2236 -496
- package/dist/cli.json +1 -1
- package/dist/esm/bin/app-template/README.md.template +3 -1
- package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +8 -4
- package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +2 -2
- package/dist/esm/bin/app-template/src/guards/owner.guard.spec.ts.template +8 -4
- package/dist/esm/bin/app-template/src/guards/owner.guard.ts.template +1 -1
- package/dist/esm/bin/app-template/vitest.config.ts.template +1 -1
- package/dist/esm/bin/app-template/vitest.setup.ts.template +7 -3
- 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 +17 -4
- 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/decorator.js +6 -6
- package/dist/esm/common/deprecation.js +2 -2
- package/dist/esm/common/errors.js +27 -2
- package/dist/esm/common/execution-context.js +1 -1
- package/dist/esm/common/meocord-text.js +11 -8
- package/dist/esm/common/metadata.js +1 -1
- 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 +21 -5
- package/dist/esm/common/sharded-cooldown-store.js +1 -1
- package/dist/esm/common/translate-error.js +1 -1
- package/dist/esm/common/translator.js +37 -6
- package/dist/esm/core/command-conflicts.js +34 -34
- package/dist/esm/core/command-registration.js +93 -24
- package/dist/esm/core/command-routes.js +2 -2
- package/dist/esm/core/component-routes.js +74 -31
- package/dist/esm/core/cooldown-runner.js +29 -8
- package/dist/esm/core/dispatcher.js +51 -7
- package/dist/esm/core/fallback.js +20 -11
- package/dist/esm/core/filter-runner.js +2 -2
- package/dist/esm/core/guard-runner.js +16 -3
- package/dist/esm/core/handler-input.js +4 -3
- package/dist/esm/core/handler-pipeline.js +27 -14
- package/dist/esm/core/handler-registry.js +1 -1
- package/dist/esm/core/interceptor-runner.js +2 -2
- package/dist/esm/core/lifecycle-hooks.js +134 -47
- package/dist/esm/core/meocord-factory.js +241 -179
- package/dist/esm/core/meocord.app.js +116 -75
- package/dist/esm/core/message-routes.js +4 -3
- package/dist/esm/core/observer-runner.js +4 -3
- package/dist/esm/core/providers.js +45 -23
- package/dist/esm/core/route-specificity.js +4 -4
- package/dist/esm/core/running-calls.js +43 -0
- package/dist/esm/core/scalar-types.js +15 -3
- package/dist/esm/core/shard-context.js +6 -7
- package/dist/esm/core/shard-manager.js +16 -10
- package/dist/esm/core/startup-roots.js +23 -4
- package/dist/esm/core/theme-resolvers.js +4 -1
- package/dist/esm/core/theme-runtime.js +2 -2
- package/dist/esm/core/theme-scope.js +3 -1
- package/dist/esm/core/theme-validation.js +17 -10
- package/dist/esm/decorator/app.decorator.js +12 -7
- package/dist/esm/decorator/command-builder.decorator.js +4 -4
- package/dist/esm/decorator/controller-class.decorator.js +9 -4
- package/dist/esm/decorator/controller.decorator.js +162 -83
- package/dist/esm/decorator/cooldown.decorator.js +20 -3
- package/dist/esm/decorator/defer.decorator.js +4 -4
- package/dist/esm/decorator/event.decorator.js +5 -5
- package/dist/esm/decorator/filter.decorator.js +7 -7
- package/dist/esm/decorator/guard.decorator.js +11 -10
- package/dist/esm/decorator/inject.decorator.js +4 -4
- package/dist/esm/decorator/interceptor.decorator.js +7 -7
- package/dist/esm/decorator/observer.decorator.js +4 -4
- package/dist/esm/decorator/service.decorator.js +4 -4
- package/dist/esm/decorator/theme.decorator.js +3 -3
- package/dist/esm/decorator/validation.decorator.js +10 -10
- package/dist/esm/package.json.js +1 -1
- package/dist/esm/testing/catalog.js +27 -11
- package/dist/esm/testing/cooldown-store-suite.js +85 -15
- package/dist/esm/testing/discord-defaults.js +249 -21
- package/dist/esm/testing/index.js +4 -2
- package/dist/esm/testing/inspect-handler.js +20 -3
- package/dist/esm/testing/meocord-testing-module.js +352 -205
- package/dist/esm/testing/mock-fn.js +173 -22
- package/dist/esm/testing/mock-interaction.js +1214 -209
- package/dist/esm/testing/response.js +1 -1
- package/dist/esm/testing/routing.js +39 -14
- package/dist/esm/testing/snowflake.js +9 -2
- package/dist/esm/testing/startup-errors.js +26 -0
- package/dist/esm/testing/strict-mocks.js +65 -0
- package/dist/esm/testing/theme.js +1 -1
- package/dist/esm/util/bundle-entry.util.js +2 -1
- 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 +3 -3
- package/dist/esm/util/meocord-config-loader.util.js +10 -2
- package/dist/esm/util/meocord-config-validation.util.js +4 -5
- package/dist/esm/util/meocord-source-config.util.js +65 -20
- package/dist/esm/util/metadata-keys.js +1 -0
- package/dist/esm/util/method-wrapper.util.js +16 -0
- package/dist/esm/util/platform.util.js +10 -10
- package/dist/esm/util/refusal.util.js +148 -8
- 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 +8 -8
- package/dist/types/common/index.d.ts +8 -8
- package/dist/types/core/index.d.cts +10 -7
- package/dist/types/core/index.d.ts +10 -7
- package/dist/types/decorator/index.d.cts +31 -29
- package/dist/types/decorator/index.d.ts +31 -29
- package/dist/types/{index-D7n0FBMm.d.ts → index-DKJclIi-.d.ts} +107 -36
- package/dist/types/{index-DEP0NYrU.d.cts → index-Dlf0ZZtS.d.cts} +107 -36
- package/dist/types/interface/index.d.cts +1 -1
- package/dist/types/interface/index.d.ts +1 -1
- package/dist/types/{route-C-aMI7Sl.d.cts → route-Bh1U13yy.d.cts} +8 -4
- package/dist/types/{route-C-aMI7Sl.d.ts → route-Bh1U13yy.d.ts} +8 -4
- package/dist/types/testing/index.d.cts +406 -70
- package/dist/types/testing/index.d.ts +406 -70
- package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
- package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
- package/dist/types/{translator-J6tIUfdp.d.ts → translator-Bg7hwxDF.d.cts} +21 -8
- package/dist/types/{translator-B4mc1lzJ.d.cts → translator-CeP4bCzp.d.ts} +21 -8
- package/package.json +4 -3
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a package kept out of the bundle is loaded, by how the bundle asks for it: an ESM `import` becomes a dynamic
|
|
3
|
+
* `import()` where its importer runs, rather than one hoisted above the pre-entry, so the package loads after the
|
|
4
|
+
* platform check and after the config has loaded `.env`; a CommonJS `require` stays a `require` at its call site, which
|
|
5
|
+
* returns the module rather than the promise a dynamic import would.
|
|
6
|
+
*/ function externalType(request, dependencyType) {
|
|
7
|
+
return `${dependencyType === 'commonjs' ? 'node-commonjs' : 'import'} ${request}`;
|
|
8
|
+
}
|
|
9
|
+
/** Whether an external's result names its own type, as `node-commonjs zlib-sync` does. */ const typed = (result)=>/\s/.test(result);
|
|
10
|
+
/**
|
|
11
|
+
* The externals with every untyped entry given its type by {@link externalType}: a string or pattern, as `autoExternal`
|
|
12
|
+
* and `externals` give them, and a function's untyped result. An object's entries are left as they are.
|
|
13
|
+
*/ function typeExternals(items) {
|
|
14
|
+
return items.map((item)=>{
|
|
15
|
+
if (typeof item === 'string' || item instanceof RegExp) {
|
|
16
|
+
const matches = (request)=>typeof item === 'string' ? request === item : item.test(request);
|
|
17
|
+
return (data, callback)=>{
|
|
18
|
+
const { request, dependencyType } = data;
|
|
19
|
+
if (request && matches(request)) return callback(null, externalType(request, dependencyType));
|
|
20
|
+
callback();
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
if (typeof item !== 'function') return item;
|
|
24
|
+
const original = item;
|
|
25
|
+
return (data, callback)=>{
|
|
26
|
+
const finish = (result)=>callback(null, typeof result === 'string' && !typed(result) ? externalType(result, data.dependencyType) : result);
|
|
27
|
+
// Callback style, as MeoCord's own native externals are, or a promise
|
|
28
|
+
if (original.length >= 2) {
|
|
29
|
+
return original(data, (error, result)=>error ? callback(error) : finish(result));
|
|
30
|
+
}
|
|
31
|
+
Promise.resolve(original(data)).then(finish, callback);
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export { externalType, typeExternals };
|
|
@@ -254,9 +254,17 @@
|
|
|
254
254
|
};
|
|
255
255
|
});
|
|
256
256
|
}
|
|
257
|
-
/**
|
|
257
|
+
/**
|
|
258
|
+
* Drops the keys whose calls have all left their window, once a minute from a call, until a sweep leaves none: an
|
|
259
|
+
* idle store then holds no timer, so one nothing references is collected, and its next call starts the sweep again.
|
|
260
|
+
*/ startSweeping() {
|
|
258
261
|
if (this.sweeper) return;
|
|
259
|
-
this.sweeper = setInterval(()=>
|
|
262
|
+
this.sweeper = setInterval(()=>{
|
|
263
|
+
sweep(this.calls, Date.now());
|
|
264
|
+
if (this.calls.size > 0) return;
|
|
265
|
+
clearInterval(this.sweeper);
|
|
266
|
+
this.sweeper = undefined;
|
|
267
|
+
}, SWEEP_INTERVAL_MS);
|
|
260
268
|
// A cooldown must never be what keeps a stopping bot alive.
|
|
261
269
|
this.sweeper.unref?.();
|
|
262
270
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { warnDeprecated } from './deprecation.js';
|
|
2
2
|
import { Logger } from './logger.js';
|
|
3
3
|
import { MetadataKey } from '../enum/metadata-key.enum.js';
|
|
4
|
-
import { refuse, decoratedName } from '../util/refusal.util.js';
|
|
4
|
+
import { declaring, refuse, decoratedName } from '../util/refusal.util.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Composes several class or method decorators into one.
|
|
@@ -30,9 +30,9 @@ import { refuse, decoratedName } from '../util/refusal.util.js';
|
|
|
30
30
|
*
|
|
31
31
|
* @group Utilities
|
|
32
32
|
* @see {@link createMetadata}
|
|
33
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
33
|
+
* @see {@link https://meocord.dev/docs/4.2/custom-decorators | Custom decorators}
|
|
34
34
|
*/ function applyDecorators(...decorators) {
|
|
35
|
-
return function(target, propertyKey, descriptor) {
|
|
35
|
+
return declaring(function(target, propertyKey, descriptor) {
|
|
36
36
|
// Last first, as TypeScript applies decorators stacked, each given what the one before it returned
|
|
37
37
|
const ordered = [
|
|
38
38
|
...decorators
|
|
@@ -45,7 +45,7 @@ import { refuse, decoratedName } from '../util/refusal.util.js';
|
|
|
45
45
|
let current = target;
|
|
46
46
|
for (const decorator of ordered)current = decorator(current) ?? current;
|
|
47
47
|
return current;
|
|
48
|
-
};
|
|
48
|
+
});
|
|
49
49
|
}
|
|
50
50
|
const logger = new Logger('SetMetadata');
|
|
51
51
|
/** The prefix of every key MeoCord keeps its own metadata under. */ const MEOCORD_PREFIX = 'meocord:';
|
|
@@ -84,7 +84,7 @@ const logger = new Logger('SetMetadata');
|
|
|
84
84
|
* @see {@link createMetadata}
|
|
85
85
|
*/ function SetMetadata(metadataKey, metadataValue) {
|
|
86
86
|
warnDeprecated(logger, 'SetMetadata', 'createMetadata');
|
|
87
|
-
return function(target, propertyKey) {
|
|
87
|
+
return declaring(function(target, propertyKey) {
|
|
88
88
|
// Checked where it applies, so the refusal names the handler or controller
|
|
89
89
|
const owner = metadataKey.startsWith(MEOCORD_PREFIX) ? `MeoCord keeps its own metadata under keys beginning "${MEOCORD_PREFIX}"` : INJECTION_KEYS.has(metadataKey) ? 'dependency injection reads how to make the class from it' : undefined;
|
|
90
90
|
if (owner) {
|
|
@@ -95,7 +95,7 @@ const logger = new Logger('SetMetadata');
|
|
|
95
95
|
} else {
|
|
96
96
|
Reflect.defineMetadata(metadataKey, metadataValue, target);
|
|
97
97
|
}
|
|
98
|
-
};
|
|
98
|
+
});
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
export { SetMetadata, applyDecorators };
|
|
@@ -14,10 +14,10 @@ const warned = new Set();
|
|
|
14
14
|
*/ function warnPlaceholder(logger, value, outcome, how) {
|
|
15
15
|
warnOnce(logger, `${value} reads a placeholder here; in the next major version (5.0) ${outcome}. ${how}`);
|
|
16
16
|
}
|
|
17
|
-
function warnOnce(logger, text) {
|
|
17
|
+
/** Logs `text` once per run, however often it is asked for. */ function warnOnce(logger, text) {
|
|
18
18
|
if (warned.has(text)) return;
|
|
19
19
|
warned.add(text);
|
|
20
20
|
logger.warn(text);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export { warnDeprecated, warnDeprecatedBehaviour, warnPlaceholder };
|
|
23
|
+
export { warnDeprecated, warnDeprecatedBehaviour, warnOnce, warnPlaceholder };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { renderText } from './meocord-text.js';
|
|
2
|
+
import { sharedKey } from '../util/shared-state.util.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Thrown by a guard to deny a call and tell the user why.
|
|
@@ -90,7 +91,7 @@ import { renderText } from './meocord-text.js';
|
|
|
90
91
|
* @group Responses
|
|
91
92
|
* @category Errors
|
|
92
93
|
* @see {@link Catch}
|
|
93
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
94
|
+
* @see {@link https://meocord.dev/docs/4.2/exception-filters | Exception filters}
|
|
94
95
|
*/ class CommandNotFoundError extends Error {
|
|
95
96
|
/** @param message - What the error says, `No handler matched the interaction.` unless given. */ constructor(message = 'No handler matched the interaction.'){
|
|
96
97
|
super(message);
|
|
@@ -163,7 +164,7 @@ import { renderText } from './meocord-text.js';
|
|
|
163
164
|
* @category Errors
|
|
164
165
|
* @see {@link MessageHandler}
|
|
165
166
|
* @see {@link translateError}
|
|
166
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
167
|
+
* @see {@link https://meocord.dev/docs/4.2/message-commands | Message commands}
|
|
167
168
|
*/ class MessageUsageError extends Error {
|
|
168
169
|
/**
|
|
169
170
|
* @param usage - The command as the user should type it, such as `!ban <target> [reason…]`, or one line per
|
|
@@ -330,5 +331,29 @@ import { renderText } from './meocord-text.js';
|
|
|
330
331
|
this.name = 'CooldownStoreError';
|
|
331
332
|
}
|
|
332
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* Makes `instanceof` hold for an error from either build of this meocord version, ES module or CommonJS: the class's
|
|
336
|
+
* prototype carries a brand both builds share, which `instanceof` on the class itself checks. A subclass, such as an
|
|
337
|
+
* app's own `UserError`, keeps the usual check, so it still matches only its own instances.
|
|
338
|
+
*/ function brand(error, name) {
|
|
339
|
+
const key = sharedKey(`error:${name}`);
|
|
340
|
+
Object.defineProperty(error.prototype, key, {
|
|
341
|
+
value: true
|
|
342
|
+
});
|
|
343
|
+
Object.defineProperty(error, Symbol.hasInstance, {
|
|
344
|
+
value (value) {
|
|
345
|
+
if (this !== error) return Function.prototype[Symbol.hasInstance].call(this, value);
|
|
346
|
+
return (typeof value === 'object' || typeof value === 'function') && value !== null && key in value;
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
// Named as written, since a minifier may rename the classes themselves
|
|
351
|
+
brand(GuardDeniedError, 'GuardDeniedError');
|
|
352
|
+
brand(UserError, 'UserError');
|
|
353
|
+
brand(CommandNotFoundError, 'CommandNotFoundError');
|
|
354
|
+
brand(ValidationError, 'ValidationError');
|
|
355
|
+
brand(MessageUsageError, 'MessageUsageError');
|
|
356
|
+
brand(CooldownError, 'CooldownError');
|
|
357
|
+
brand(CooldownStoreError, 'CooldownStoreError');
|
|
333
358
|
|
|
334
359
|
export { CommandNotFoundError, CooldownError, CooldownStoreError, GuardDeniedError, MessageUsageError, UserError, ValidationError, cooldownMessage, cooldownStoreMessage, cooldownText, usageHeading };
|
|
@@ -33,7 +33,7 @@ import { useTheme } from '../core/theme-scope.js';
|
|
|
33
33
|
* @group Utilities
|
|
34
34
|
* @see {@link createMetadata}
|
|
35
35
|
* @see {@link StageParams}
|
|
36
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
36
|
+
* @see {@link https://meocord.dev/docs/4.2/guards | Guards}
|
|
37
37
|
*/ class ExecutionContext {
|
|
38
38
|
/**
|
|
39
39
|
* The response state of the interaction being handled, the same one `respond(interaction)` returns,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fillPlaceholders, pluralForm, lookup, languageOf, LOCALE_CHAIN, CATALOGS } from './translator.js';
|
|
2
2
|
import { MEOCORD_MESSAGES } from './meocord-messages.js';
|
|
3
|
+
import { shared } from '../util/shared-state.util.js';
|
|
3
4
|
|
|
4
5
|
const hasCatalogs = (translator)=>translator !== undefined && CATALOGS in translator && LOCALE_CHAIN in translator;
|
|
5
6
|
const ENGLISH = 'en-US';
|
|
@@ -31,16 +32,17 @@ const ENGLISH = 'en-US';
|
|
|
31
32
|
return english();
|
|
32
33
|
}
|
|
33
34
|
const lists = new Map();
|
|
34
|
-
/**
|
|
35
|
-
let format = lists.get(
|
|
36
|
-
if (!format) lists.set(
|
|
37
|
-
type:
|
|
35
|
+
/** An `or` list in a locale's words, such as "a, b, or c" in en-US. */ function orListIn(locale, items) {
|
|
36
|
+
let format = lists.get(locale);
|
|
37
|
+
if (!format) lists.set(locale, format = new Intl.ListFormat(locale, {
|
|
38
|
+
type: 'disjunction'
|
|
38
39
|
}));
|
|
39
40
|
return format.format(items);
|
|
40
41
|
}
|
|
41
42
|
/**
|
|
42
|
-
* A text in `locale`, as {@link findText} finds it.
|
|
43
|
-
*
|
|
43
|
+
* A text in `locale`, as {@link findText} finds it. An `or` list in an app's catalog reads in the words of the locale
|
|
44
|
+
* that has it; any other list is joined with its `joiner`, a comma unless given, as Intl's `unit` lists join with
|
|
45
|
+
* nothing in zh-CN and with a space in ja.
|
|
44
46
|
*/ function renderText(translator, locale, text) {
|
|
45
47
|
const found = findText(translator, locale, text.key);
|
|
46
48
|
if (!found) return text.fallback ?? text.key;
|
|
@@ -49,14 +51,15 @@ const lists = new Map();
|
|
|
49
51
|
if (!Object.hasOwn(params, name)) return whole;
|
|
50
52
|
const value = params[name];
|
|
51
53
|
if (typeof value !== 'object') return String(value);
|
|
52
|
-
if ('list' in value) return found.own ?
|
|
54
|
+
if ('list' in value) return found.own && value.style === 'or' ? orListIn(found.locale, value.list) : value.list.join(value.joiner ?? ', ');
|
|
53
55
|
return renderText(translator, locale, value);
|
|
54
56
|
});
|
|
55
57
|
}
|
|
56
58
|
/** Renders texts in one locale through one translator: a word or number as it is, a text in the locale's words. */ function textRenderer(translator, locale) {
|
|
57
59
|
return (text)=>typeof text !== 'object' ? String(text) : 'list' in text ? text.list.join(text.joiner ?? ', ') : renderText(translator, locale, text);
|
|
58
60
|
}
|
|
59
|
-
|
|
61
|
+
// Shared by both builds of this version, so either answers in the app's language
|
|
62
|
+
const translators = shared('clientTranslators', ()=>new WeakMap());
|
|
60
63
|
/** Records the translator of the app a client's interactions and messages come to. */ function registerClientTranslator(client, translator) {
|
|
61
64
|
if (translator) translators.set(client, translator);
|
|
62
65
|
else translators.delete(client);
|
|
@@ -31,7 +31,7 @@ import 'reflect-metadata';
|
|
|
31
31
|
*
|
|
32
32
|
* @group Utilities
|
|
33
33
|
* @see {@link ExecutionContext}
|
|
34
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
34
|
+
* @see {@link https://meocord.dev/docs/4.2/guards | Guards}
|
|
35
35
|
*/ function createMetadata(description) {
|
|
36
36
|
const key = Symbol(description);
|
|
37
37
|
const decorator = (value)=>function(target, propertyKey) {
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { shared } from '../../util/shared-state.util.js';
|
|
2
|
+
|
|
3
|
+
// One count for both builds of this version, so calls either makes merge in the order made
|
|
4
|
+
const calls = shared('callOrder', ()=>({
|
|
5
|
+
last: 0,
|
|
6
|
+
order: new WeakMap()
|
|
7
|
+
}));
|
|
3
8
|
/** Marks a Discord call as made now, so calls recorded in different places can be merged in the order made. */ function stampCall(call) {
|
|
4
|
-
order.set(call, ++last);
|
|
9
|
+
calls.order.set(call, ++calls.last);
|
|
5
10
|
}
|
|
6
11
|
/** When a call stamped by {@link stampCall} was made, relative to the others. */ function callOrder(call) {
|
|
7
|
-
return order.get(call) ?? 0;
|
|
12
|
+
return calls.order.get(call) ?? 0;
|
|
8
13
|
}
|
|
9
14
|
|
|
10
15
|
export { callOrder, stampCall };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EmbedBuilder, ContainerBuilder, resolveColor, TextDisplayBuilder, SectionBuilder, ThumbnailBuilder, MediaGalleryBuilder, FileBuilder, ComponentType, AttachmentBuilder } from 'discord.js';
|
|
2
2
|
import { renderText, translatorOfClient } from '../meocord-text.js';
|
|
3
|
+
import { shared } from '../../util/shared-state.util.js';
|
|
3
4
|
|
|
4
5
|
/** One of the presenter's texts, in the context's locale through the app's translator; English for a context without one. */ const text = ({ interaction, locale }, key)=>renderText(translatorOfClient(interaction?.client), locale, {
|
|
5
6
|
key
|
|
@@ -21,16 +22,41 @@ import { renderText, translatorOfClient } from '../meocord-text.js';
|
|
|
21
22
|
})
|
|
22
23
|
};
|
|
23
24
|
/** The id MeoCord gives the containers it renders, so a view left behind can be found again. */ const RENDERED_CONTAINER_ID = 0x4d43;
|
|
24
|
-
|
|
25
|
+
// Shared by both builds of this version, so either answers with the app's presenter
|
|
26
|
+
const presenters = shared('clientPresenters', ()=>new WeakMap());
|
|
25
27
|
/** Sets the presenter for the interactions a client receives. */ function setPresenter(client, presenter) {
|
|
26
28
|
presenters.set(client, presenter);
|
|
27
29
|
}
|
|
28
30
|
/** The presenter for an interaction's client, or the default one. */ function presenterFor(client) {
|
|
29
31
|
return client && presenters.get(client) || defaultPresenter;
|
|
30
32
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
/** Discord's limits on a message's text, counted by `String.length` as discord.js's builders count them. */ const TEXT_LIMITS = {
|
|
34
|
+
embed: 4096,
|
|
35
|
+
v2: 4000,
|
|
36
|
+
content: 2000
|
|
37
|
+
};
|
|
38
|
+
/** `text` cut to `limit` characters ending in `…`, on a code point boundary so no surrogate pair is split. */ function fitText(text, limit) {
|
|
39
|
+
if (text.length <= limit) return text;
|
|
40
|
+
let cut = text.slice(0, Math.max(0, limit - 1));
|
|
41
|
+
const last = cut.charCodeAt(cut.length - 1);
|
|
42
|
+
if (last >= 0xd800 && last <= 0xdbff) cut = cut.slice(0, -1);
|
|
43
|
+
return `${cut}…`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* An error's message as a presenter is given it: fitted to `limit`, and, when empty or only whitespace, which Discord
|
|
47
|
+
* refuses as a text, MeoCord's generic error text, so a presenter that writes it as its text always renders.
|
|
48
|
+
*/ function presentedMessage(message, limit, generic) {
|
|
49
|
+
return fitText(message.trim() === '' ? generic() : message, limit);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The view's text after what MeoCord puts before it, its emoji and a Text Display's title line. When only those take
|
|
53
|
+
* the two past `limit`, the text is cut so they fit; a text past `limit` by itself is left, a view MeoCord can't render.
|
|
54
|
+
*/ function textAfter(prefix, view, limit) {
|
|
55
|
+
const total = prefix.length + view.text.length;
|
|
56
|
+
if (total <= limit || view.text.length > limit || prefix.length >= limit) return prefix + view.text;
|
|
57
|
+
return prefix + fitText(view.text, limit - prefix.length);
|
|
33
58
|
}
|
|
59
|
+
const emojiOf = (view)=>view.emoji ? `${view.emoji} ` : '';
|
|
34
60
|
/** Discord's limit of attachments on one message. */ const ATTACHMENT_LIMIT = 10;
|
|
35
61
|
/** The most items Discord shows in one media gallery. */ const MEDIA_GALLERY_LIMIT = 10;
|
|
36
62
|
/**
|
|
@@ -114,7 +140,7 @@ const IMAGE = /\.(png|jpe?g|gif|webp)$/i;
|
|
|
114
140
|
return (view.files ?? []).filter((file)=>!shown.has(`attachment://${nameOf(file)}`));
|
|
115
141
|
}
|
|
116
142
|
/** A view as an embed: its image, or else its first image file not its thumbnail, as the embed's image. */ function renderEmbed(view) {
|
|
117
|
-
const embed = new EmbedBuilder().setDescription(
|
|
143
|
+
const embed = new EmbedBuilder().setDescription(textAfter(emojiOf(view), view, TEXT_LIMITS.embed));
|
|
118
144
|
if (view.title) embed.setTitle(view.title);
|
|
119
145
|
if (view.color !== undefined) embed.setColor(view.color);
|
|
120
146
|
const image = view.image ?? unshownFiles(view).map(nameOf).find((name)=>IMAGE.test(name));
|
|
@@ -125,7 +151,8 @@ const IMAGE = /\.(png|jpe?g|gif|webp)$/i;
|
|
|
125
151
|
/** A view as a Components V2 container, carrying MeoCord's id. */ function renderContainer(view) {
|
|
126
152
|
const container = new ContainerBuilder().setId(RENDERED_CONTAINER_ID);
|
|
127
153
|
if (view.color !== undefined) container.setAccentColor(resolveColor(view.color));
|
|
128
|
-
const
|
|
154
|
+
const titleLine = view.title ? `### ${view.title}\n` : '';
|
|
155
|
+
const text = new TextDisplayBuilder().setContent(textAfter(titleLine + emojiOf(view), view, TEXT_LIMITS.v2));
|
|
129
156
|
if (view.thumbnail) {
|
|
130
157
|
container.addSectionComponents(new SectionBuilder().addTextDisplayComponents(text).setThumbnailAccessory(new ThumbnailBuilder().setURL(urlOf(view, view.thumbnail))));
|
|
131
158
|
} else container.addTextDisplayComponents(text);
|
|
@@ -158,4 +185,4 @@ const IMAGE = /\.(png|jpe?g|gif|webp)$/i;
|
|
|
158
185
|
};
|
|
159
186
|
}
|
|
160
187
|
|
|
161
|
-
export { ATTACHMENT_LIMIT, DEFAULT_ATTACHMENT_SIZE_LIMIT, REFUSED_AS_TOO_LARGE, RENDERED_CONTAINER_ID, attachmentsOf, defaultPresenter, filesProblem, isTooLarge, presenterFor, renderContainer, renderEmbed, setPresenter, withSendableFiles, withoutFiles };
|
|
188
|
+
export { ATTACHMENT_LIMIT, DEFAULT_ATTACHMENT_SIZE_LIMIT, REFUSED_AS_TOO_LARGE, RENDERED_CONTAINER_ID, TEXT_LIMITS, attachmentsOf, defaultPresenter, filesProblem, fitText, isTooLarge, presentedMessage, presenterFor, renderContainer, renderEmbed, setPresenter, withSendableFiles, withoutFiles };
|
|
@@ -8,12 +8,13 @@ import { describeInteraction } from '../../util/interaction.util.js';
|
|
|
8
8
|
import { getInstallContext } from './install-context.js';
|
|
9
9
|
import { resolveFlags, flagNames, hasEphemeral, hasComponentsV2 } from './flags.js';
|
|
10
10
|
import { rewriteAttachmentUrls, keptAttachmentNames } from './attachments.js';
|
|
11
|
-
import { renderEmbed, isTooLarge, REFUSED_AS_TOO_LARGE, withoutFiles, renderContainer, attachmentsOf, withSendableFiles, presenterFor, defaultPresenter, DEFAULT_ATTACHMENT_SIZE_LIMIT } from './presenter.js';
|
|
11
|
+
import { renderEmbed, isTooLarge, REFUSED_AS_TOO_LARGE, withoutFiles, renderContainer, attachmentsOf, withSendableFiles, presenterFor, presentedMessage, TEXT_LIMITS, defaultPresenter, DEFAULT_ATTACHMENT_SIZE_LIMIT } from './presenter.js';
|
|
12
12
|
import { sameEmbed, withoutRenderedViews, lockComponents, countComponents, V2_COMPONENT_LIMIT, EMBED_LIMIT } from './components.js';
|
|
13
13
|
import { stampCall } from './call-order.js';
|
|
14
14
|
import { isUserOutcome } from '../user-outcome.js';
|
|
15
15
|
import { themeForInteraction } from '../../core/theme-scope.js';
|
|
16
16
|
import { LOADING_DRAW_TIMEOUT_MS } from '../../core/theme-resolvers.js';
|
|
17
|
+
import { shared } from '../../util/shared-state.util.js';
|
|
17
18
|
|
|
18
19
|
const ALREADY_ACKNOWLEDGED = 40060;
|
|
19
20
|
/** Discord's code for an interaction it no longer knows, such as one past its three seconds unacknowledged. */ const UNKNOWN_INTERACTION = 10062;
|
|
@@ -55,7 +56,8 @@ function toBody(payload) {
|
|
|
55
56
|
*/ function editedSince(stamp, ours) {
|
|
56
57
|
return typeof stamp === 'number' && ours !== undefined && stamp > ours;
|
|
57
58
|
}
|
|
58
|
-
|
|
59
|
+
// Both builds of this meocord version keep one, so a message one locks, the other sees locked
|
|
60
|
+
const messageLocks = shared('messageLocks', ()=>new Map());
|
|
59
61
|
/** How long a locked message is remembered once no call holds it. */ const LOCK_MEMORY_MS = 60000;
|
|
60
62
|
/**
|
|
61
63
|
* A body with the theme's primary colour where the app left one unset: an embed with no `color`, and a
|
|
@@ -117,9 +119,12 @@ function toJson(value) {
|
|
|
117
119
|
return this.phase;
|
|
118
120
|
}
|
|
119
121
|
get message() {
|
|
122
|
+
// A deleted answer, or a follow-up in its place, leaves the component's own message behind
|
|
123
|
+
if (this.answer.kind !== 'original') return this.lastMessage;
|
|
120
124
|
return this.lastMessage ?? ('message' in this.interaction ? this.interaction.message ?? undefined : undefined);
|
|
121
125
|
}
|
|
122
126
|
get original() {
|
|
127
|
+
// Each component is a top-level component's API JSON, as `toJSON()` gave it; the lock reads them as plain JSON
|
|
123
128
|
return this.snapshot;
|
|
124
129
|
}
|
|
125
130
|
/** The Discord calls made through this state, for tests. */ get history() {
|
|
@@ -451,9 +456,11 @@ function toJson(value) {
|
|
|
451
456
|
this.sync();
|
|
452
457
|
const body = await this.themed(payload, options);
|
|
453
458
|
if (this.phase === 'unanswered') return this.reply(body);
|
|
454
|
-
|
|
459
|
+
// Once the answer is deleted, the next message sent is the answer
|
|
460
|
+
if (this.answer.kind === 'deleted') return this.answerWith(body);
|
|
461
|
+
if (this.phase === 'deferred' && answersWithOwnMessage(this.interaction) && this.answer.kind === 'original') {
|
|
455
462
|
// Discord makes a follow-up to a deferred, unsent reply that reply, ignoring its flags: a private one
|
|
456
|
-
// would be shown to everyone on a public deferral, so that deferral is deleted
|
|
463
|
+
// would be shown to everyone on a public deferral, so that deferral is deleted, and the follow-up is the answer.
|
|
457
464
|
const requested = Number(MessageFlagsBitField.resolve(body.flags ?? 0));
|
|
458
465
|
if (!hasEphemeral(requested)) return this.editMessage(body);
|
|
459
466
|
// A private deferral is already what the flag asks for, which an edit cannot take
|
|
@@ -463,7 +470,11 @@ function toJson(value) {
|
|
|
463
470
|
});
|
|
464
471
|
await this.call('deleteReply', undefined, ()=>this.interaction.deleteReply());
|
|
465
472
|
this.phase = 'replied';
|
|
473
|
+
return this.answerWith(body);
|
|
466
474
|
}
|
|
475
|
+
return (await this.sendFollowUp(body)).message;
|
|
476
|
+
}
|
|
477
|
+
/** Sends a follow-up, with the flags it was sent with. */ async sendFollowUp(body) {
|
|
467
478
|
const flags = this.withSuppression(this.flagsFor('followUp', body.flags, false));
|
|
468
479
|
const sent = forMode(body, hasComponentsV2(flags));
|
|
469
480
|
const message = await this.call('followUp', {
|
|
@@ -473,6 +484,20 @@ function toJson(value) {
|
|
|
473
484
|
...sent,
|
|
474
485
|
flags
|
|
475
486
|
}));
|
|
487
|
+
return {
|
|
488
|
+
message: message,
|
|
489
|
+
flags
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
/** Sends a follow-up that becomes the answer, which later edits and `delete()` act on by its id. */ async answerWith(body) {
|
|
493
|
+
const { message, flags } = await this.sendFollowUp(body);
|
|
494
|
+
// Its privacy as sent, not as read back from the message
|
|
495
|
+
this.answer = {
|
|
496
|
+
kind: 'followUp',
|
|
497
|
+
id: message.id,
|
|
498
|
+
ephemeral: hasEphemeral(flags)
|
|
499
|
+
};
|
|
500
|
+
this.lastMessage = message;
|
|
476
501
|
return message;
|
|
477
502
|
}
|
|
478
503
|
delete() {
|
|
@@ -480,7 +505,22 @@ function toJson(value) {
|
|
|
480
505
|
this.sync();
|
|
481
506
|
if (this.phase === 'unanswered') throw new Error('There is no answer to delete: the interaction has not been answered.');
|
|
482
507
|
this.refuseAfterModal();
|
|
483
|
-
|
|
508
|
+
const { answer } = this;
|
|
509
|
+
if (answer.kind === 'deleted') throw new Error('There is no answer to delete: it was deleted.');
|
|
510
|
+
// A follow-up is as private as it was sent, a command's own reply as the interaction's answer, a component's
|
|
511
|
+
// message as its flags
|
|
512
|
+
const shown = this.message?.flags?.has(MessageFlags.Ephemeral) ?? false;
|
|
513
|
+
const ephemeral = answer.kind === 'followUp' ? answer.ephemeral : answersWithOwnMessage(this.interaction) ? Boolean(this.interaction.ephemeral) || shown : shown;
|
|
514
|
+
if (answer.kind === 'followUp') await this.call('deleteReply', answer.id, ()=>this.interaction.deleteReply(answer.id));
|
|
515
|
+
else await this.call('deleteReply', undefined, ()=>this.interaction.deleteReply());
|
|
516
|
+
this.answer = {
|
|
517
|
+
kind: 'deleted',
|
|
518
|
+
ephemeral
|
|
519
|
+
};
|
|
520
|
+
this.lastMessage = undefined;
|
|
521
|
+
// The message is gone: nothing is left to put back as it was before a lock
|
|
522
|
+
this.settled = true;
|
|
523
|
+
this.leave();
|
|
484
524
|
});
|
|
485
525
|
}
|
|
486
526
|
/** A modal is the interaction's whole answer: what the user enters arrives as a submit interaction of its own. */ refuseAfterModal() {
|
|
@@ -590,7 +630,20 @@ function toJson(value) {
|
|
|
590
630
|
withAttachments(body) {
|
|
591
631
|
return rewriteAttachmentUrls(body, keptAttachmentNames(body, this.message?.attachments?.values() ?? []));
|
|
592
632
|
}
|
|
593
|
-
/**
|
|
633
|
+
/**
|
|
634
|
+
* Edits the answer through the interaction, and through the channel only once its token has expired. Once the answer
|
|
635
|
+
* is deleted, the edit is sent as a follow-up that becomes the answer, as private as the deleted one unless it sets
|
|
636
|
+
* flags of its own.
|
|
637
|
+
*/ async editMessage(body, { restoring = false } = {}) {
|
|
638
|
+
const { answer } = this;
|
|
639
|
+
if (answer.kind === 'deleted') {
|
|
640
|
+
if (restoring) return undefined;
|
|
641
|
+
const { attachments: _attachments, ...rest } = body;
|
|
642
|
+
return this.answerWith({
|
|
643
|
+
...rest,
|
|
644
|
+
flags: body.flags ?? (answer.ephemeral ? MessageFlags.Ephemeral : 0)
|
|
645
|
+
});
|
|
646
|
+
}
|
|
594
647
|
if (!restoring) {
|
|
595
648
|
this.settled = true;
|
|
596
649
|
this.leave();
|
|
@@ -612,8 +665,13 @@ function toJson(value) {
|
|
|
612
665
|
...this.withAttachments(forMode(body, this.v2)),
|
|
613
666
|
flags
|
|
614
667
|
};
|
|
668
|
+
// A follow-up that is the answer is edited by its id
|
|
669
|
+
const edit = answer.kind === 'followUp' ? {
|
|
670
|
+
...sent,
|
|
671
|
+
message: answer.id
|
|
672
|
+
} : sent;
|
|
615
673
|
try {
|
|
616
|
-
this.lastMessage = await this.call('editReply',
|
|
674
|
+
this.lastMessage = await this.call('editReply', edit, ()=>this.interaction.editReply(edit));
|
|
617
675
|
} catch (error) {
|
|
618
676
|
const message = this.message;
|
|
619
677
|
const expired = Date.now() - this.interaction.createdTimestamp >= TOKEN_EXPIRED_AFTER_MS;
|
|
@@ -732,12 +790,16 @@ function toJson(value) {
|
|
|
732
790
|
* place, and the failure is returned for the caller to report once the user is answered.
|
|
733
791
|
*/ async drawnView(error, message, theme) {
|
|
734
792
|
const tone = isUserOutcome(error, this.interaction) ? 'warning' : 'danger';
|
|
793
|
+
const context = this.presenterContext(this.v2, theme);
|
|
794
|
+
// Fitted to the mode's limit first, so a presenter that writes the message as its text, MeoCord's own included, renders
|
|
795
|
+
const generic = ()=>textFor(this.interaction, {
|
|
796
|
+
key: 'meocord.fallback.error'
|
|
797
|
+
});
|
|
735
798
|
const presented = {
|
|
736
|
-
message,
|
|
799
|
+
message: presentedMessage(message, TEXT_LIMITS[context.mode], generic),
|
|
737
800
|
error,
|
|
738
801
|
tone
|
|
739
802
|
};
|
|
740
|
-
const context = this.presenterContext(this.v2, theme);
|
|
741
803
|
const ready = (view)=>{
|
|
742
804
|
const sendable = this.sendable(themedView(view, theme), this.keptBeside());
|
|
743
805
|
// Rendered once here, so a view MeoCord cannot render fails before anything is sent
|
|
@@ -880,6 +942,11 @@ function toJson(value) {
|
|
|
880
942
|
await this.followUpNow(this.privateError(view));
|
|
881
943
|
return;
|
|
882
944
|
}
|
|
945
|
+
// A deleted message has nothing to add the error to or put back: the error follows up, and is the answer
|
|
946
|
+
if (this.answer.kind === 'deleted') {
|
|
947
|
+
await this.followUpNow(this.privateError(view));
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
883
950
|
// The message the component is on: whether it is private does not change with edits.
|
|
884
951
|
const current = 'message' in this.interaction ? this.interaction.message ?? undefined : this.message;
|
|
885
952
|
if (current?.flags?.has(MessageFlags.Ephemeral)) {
|
|
@@ -925,6 +992,12 @@ function toJson(value) {
|
|
|
925
992
|
/** The answer steps asked for, run one after another; `pending` counts those not yet settled. */ this.queue = Promise.resolve();
|
|
926
993
|
this.pending = 0;
|
|
927
994
|
/** Whether the answer was a modal, which has no message to edit or delete. */ this.modalShown = false;
|
|
995
|
+
/**
|
|
996
|
+
* Which message is the answer: the interaction's own reply, a follow-up that took its place, or none since `delete()`,
|
|
997
|
+
* with whether the deleted one was private. `sync()` leaves it alone, so every edit follows it.
|
|
998
|
+
*/ this.answer = {
|
|
999
|
+
kind: 'original'
|
|
1000
|
+
};
|
|
928
1001
|
this.calls = [];
|
|
929
1002
|
/** Whether the interaction was acknowledged with a private deferred reply for a drawn error view, which that view replaces. */ this.privateDeferral = false;
|
|
930
1003
|
/** Whether the locked message was answered, so nothing restores it again. */ this.settled = false;
|
|
@@ -936,7 +1009,8 @@ function toJson(value) {
|
|
|
936
1009
|
this.v2 = Boolean(message?.flags?.has(MessageFlags.IsComponentsV2));
|
|
937
1010
|
}
|
|
938
1011
|
}
|
|
939
|
-
|
|
1012
|
+
// One state per interaction for both builds of this version, so either sees where the other left its answer
|
|
1013
|
+
const states = shared('responseStates', ()=>new WeakMap());
|
|
940
1014
|
/** The response state of a repliable interaction, created on first use. */ function responseOf(interaction) {
|
|
941
1015
|
let state = states.get(interaction);
|
|
942
1016
|
if (!state) states.set(interaction, state = new InteractionResponse(interaction));
|
package/dist/esm/common/route.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { createRegexFromPattern } from '../decorator/controller.decorator.js';
|
|
1
|
+
import { createRegexFromPattern, literalBraces, MAX_CUSTOM_ID_LENGTH } from '../decorator/controller.decorator.js';
|
|
2
2
|
import { parseSegment } from '../core/scalar-types.js';
|
|
3
|
+
import { Logger } from './logger.js';
|
|
4
|
+
import { warnOnce } from './deprecation.js';
|
|
3
5
|
|
|
4
|
-
|
|
6
|
+
const logger = new Logger('Route');
|
|
5
7
|
const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
|
|
6
8
|
/** Encodes the characters that would end a param's segment, or read as an encoding, in a customId. */ const encodeSegment = (value)=>value.replace(/%/g, '%25').replace(/\//g, '%2F');
|
|
7
9
|
/**
|
|
@@ -14,6 +16,10 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
|
|
|
14
16
|
decodeSegment(value)
|
|
15
17
|
]));
|
|
16
18
|
}
|
|
19
|
+
/** The segment types whose value is text, which `build` takes only as a string. */ const TEXT_TYPES = new Set([
|
|
20
|
+
'snowflake',
|
|
21
|
+
'uuid'
|
|
22
|
+
]);
|
|
17
23
|
/**
|
|
18
24
|
* Makes a typed route from a customId pattern, so one declaration serves the handler and the ids that reach it.
|
|
19
25
|
*
|
|
@@ -26,7 +32,8 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
|
|
|
26
32
|
* fails to compile, and a typed param takes a value of its type.
|
|
27
33
|
*
|
|
28
34
|
* @param pattern - The customId pattern, where `{name}` captures one `/`-separated segment and `{name:int}` a
|
|
29
|
-
* typed one.
|
|
35
|
+
* typed one. A param's name is ASCII letters, digits and _: a brace pair such as `{café}` is literal text, and is
|
|
36
|
+
* warned about.
|
|
30
37
|
* @returns A route whose `build` takes a value for each param.
|
|
31
38
|
* @throws Error when the pattern cannot be read, as `@Command` would throw for it.
|
|
32
39
|
*
|
|
@@ -48,9 +55,12 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
|
|
|
48
55
|
*
|
|
49
56
|
* @group Utilities
|
|
50
57
|
* @see {@link Route}
|
|
51
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
58
|
+
* @see {@link https://meocord.dev/docs/4.2/components | Buttons, selects and modals}
|
|
52
59
|
*/ function route(pattern) {
|
|
53
60
|
const { params, types } = createRegexFromPattern(pattern);
|
|
61
|
+
const braces = literalBraces(pattern);
|
|
62
|
+
// Once per pattern, as a route made inside a handler is made on every call
|
|
63
|
+
if (braces) warnOnce(logger, `Pattern "${pattern}": ${braces}`);
|
|
54
64
|
const names = new Set(params);
|
|
55
65
|
const build = (values = {})=>{
|
|
56
66
|
const unknown = Object.keys(values).filter((name)=>!names.has(name));
|
|
@@ -60,6 +70,11 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
|
|
|
60
70
|
const value = Object.hasOwn(values, name) ? values[name] : undefined;
|
|
61
71
|
const type = Object.hasOwn(types, name) ? types[name] : undefined;
|
|
62
72
|
if (value === undefined || value === null) throw new TypeError(`route('${pattern}').build() needs a value for ${placeholder}.`);
|
|
73
|
+
// A snowflake's or uuid's value is its text: a number for a snowflake may have lost digits before it got here
|
|
74
|
+
if (type && TEXT_TYPES.has(type) && typeof value !== 'string') {
|
|
75
|
+
const lost = type === 'snowflake' ? ' A number may have lost digits already: pass the ID as text, such as user.id.' : '';
|
|
76
|
+
throw new TypeError(`route('${pattern}').build() got the ${typeof value} ${String(value)} for ${placeholder}, which takes its value as a string.${lost}`);
|
|
77
|
+
}
|
|
63
78
|
const text = String(value);
|
|
64
79
|
if (text === '') throw new TypeError(`route('${pattern}').build() got an empty {${name}}, which no customId segment can hold.`);
|
|
65
80
|
// A typed segment must read back as the value it was built from, or the route could never match it
|
|
@@ -69,6 +84,7 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
|
|
|
69
84
|
}
|
|
70
85
|
return encodeSegment(text);
|
|
71
86
|
});
|
|
87
|
+
if (id === '') throw new RangeError(`route('${pattern}').build() made an empty customId; Discord's customIds are 1 to ${MAX_CUSTOM_ID_LENGTH} characters.`);
|
|
72
88
|
if (id.length > MAX_CUSTOM_ID_LENGTH) {
|
|
73
89
|
throw new RangeError(`route('${pattern}').build() made a customId of ${id.length} characters, over Discord's ${MAX_CUSTOM_ID_LENGTH}: "${id}".`);
|
|
74
90
|
}
|
|
@@ -81,4 +97,4 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
|
|
|
81
97
|
});
|
|
82
98
|
}
|
|
83
99
|
|
|
84
|
-
export {
|
|
100
|
+
export { decodeRouteParams, route };
|
|
@@ -9,7 +9,7 @@ import { isShardProcess } from '../util/shard-process.util.js';
|
|
|
9
9
|
* never held forever. MeoCord itself fails the call after `cooldownStoreTimeoutMs`, a second by default.
|
|
10
10
|
*/ const SHARDED_COOLDOWN_ABANDON_MS = 30000;
|
|
11
11
|
/** A shard's channel to its manager, or none outside process sharding. */ function managerChannel() {
|
|
12
|
-
return isShardProcess() && typeof process.send === 'function' ? channelOver(process) : undefined;
|
|
12
|
+
return isShardProcess(process.env) && typeof process.send === 'function' ? channelOver(process) : undefined;
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
15
|
* The channel over a process's IPC. A send on a closed channel does not throw: without a callback, Node raises an
|
|
@@ -58,7 +58,7 @@ import { interactionLocale, messageLocale, textOfIssue, renderText } from './meo
|
|
|
58
58
|
* @group Utilities
|
|
59
59
|
* @category Localisation
|
|
60
60
|
* @see {@link createTranslator}
|
|
61
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
61
|
+
* @see {@link https://meocord.dev/docs/4.2/localisation | Localisation}
|
|
62
62
|
*/ function translateError(error, t, target) {
|
|
63
63
|
const locale = target instanceof BaseInteraction ? interactionLocale(target) : target instanceof Message ? messageLocale(target) : target;
|
|
64
64
|
return errorText(error, t, locale);
|