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
|
@@ -222,19 +222,37 @@ const logger = new Logger('Cooldown');
|
|
|
222
222
|
const value = by ? await by(context, params) : undefined;
|
|
223
223
|
// Encoded, so a value holding a colon cannot count under another value's key. Without a value, `:by` alone keeps
|
|
224
224
|
// the call apart from a cooldown with no `by` over the same window.
|
|
225
|
-
const suffix = !by ? '' : value === undefined ? ':by' : `:by:${
|
|
225
|
+
const suffix = !by ? '' : value === undefined ? ':by' : `:by:${encodeKeyPart(String(value))}`;
|
|
226
|
+
const scope = scopeId(per, first);
|
|
227
|
+
// The caller, for a notice of a refusal under a scope that counts more than one caller
|
|
228
|
+
const user = scope.startsWith('user:') ? undefined : caller(first).user;
|
|
226
229
|
counted.push({
|
|
227
|
-
key: `${controller.name}.${methodName}#${id}:${per}:${
|
|
230
|
+
key: `${controller.name}.${methodName}#${id}:${per}:${scope}${suffix}`,
|
|
228
231
|
windowMs,
|
|
229
232
|
uses,
|
|
230
|
-
per
|
|
233
|
+
per,
|
|
234
|
+
user
|
|
231
235
|
});
|
|
232
236
|
}
|
|
233
237
|
return counted;
|
|
234
238
|
}
|
|
239
|
+
// A surrogate with no partner beside it, which encodeURIComponent refuses
|
|
240
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
235
241
|
/**
|
|
236
|
-
*
|
|
237
|
-
*
|
|
242
|
+
* A value as part of a store key: each well-formed run as `encodeURIComponent` writes it, and each lone surrogate as `%u`
|
|
243
|
+
* and its four hex digits, which no other value encodes to, since `encodeURIComponent` writes `%` only before two.
|
|
244
|
+
*/ function encodeKeyPart(value) {
|
|
245
|
+
let encoded = '';
|
|
246
|
+
let from = 0;
|
|
247
|
+
for (const { 0: surrogate, index } of value.matchAll(LONE_SURROGATE)){
|
|
248
|
+
encoded += `${encodeURIComponent(value.slice(from, index))}%u${surrogate.charCodeAt(0).toString(16).toUpperCase()}`;
|
|
249
|
+
from = index + 1;
|
|
250
|
+
}
|
|
251
|
+
return encoded + encodeURIComponent(value.slice(from));
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* The store key and window of the cooldown each refusal came from, when its wait ends by the store's clock if the
|
|
255
|
+
* store said, and the refused caller when the key counts more than one, for {@link claimCooldownNotice}.
|
|
238
256
|
*/ const refusalKeys = new WeakMap();
|
|
239
257
|
/** Asks the store about the call under the app's policy, and throws what the answer means for it. */ async function ask(container, counted, peek, call) {
|
|
240
258
|
const store = cooldownStoreOf(container);
|
|
@@ -267,7 +285,8 @@ const logger = new Logger('Cooldown');
|
|
|
267
285
|
refusalKeys.set(refusal, {
|
|
268
286
|
key: blocking.key,
|
|
269
287
|
windowMs: blocking.windowMs,
|
|
270
|
-
endsAt: verdict.retryTimestamp
|
|
288
|
+
endsAt: verdict.retryTimestamp,
|
|
289
|
+
user: blocking.user
|
|
271
290
|
});
|
|
272
291
|
throw refusal;
|
|
273
292
|
}
|
|
@@ -290,16 +309,18 @@ const logger = new Logger('Cooldown');
|
|
|
290
309
|
// retry landing a moment late is refused on the earlier, which its wait holds, before taking the later, which a wait
|
|
291
310
|
// ending then needs for its own notice.
|
|
292
311
|
const bucket = Math.floor(refusal.retryAt.getTime() / NOTICE_BUCKET_MS);
|
|
312
|
+
// A key counting a server, a channel or everyone is shared by every caller it refuses, each told once
|
|
313
|
+
const key = refused.user === undefined ? refused.key : `${refused.key}:user:${refused.user}`;
|
|
293
314
|
const notices = refused.endsAt !== undefined ? [
|
|
294
315
|
{
|
|
295
|
-
key: `${
|
|
316
|
+
key: `${key}:notice:at:${refused.endsAt}`,
|
|
296
317
|
limit
|
|
297
318
|
}
|
|
298
319
|
] : [
|
|
299
320
|
bucket - 1,
|
|
300
321
|
bucket
|
|
301
322
|
].map((at)=>({
|
|
302
|
-
key: `${
|
|
323
|
+
key: `${key}:notice:${at}`,
|
|
303
324
|
limit
|
|
304
325
|
}));
|
|
305
326
|
try {
|
|
@@ -38,6 +38,7 @@ import { existingResponse } from '../common/response/response-state.js';
|
|
|
38
38
|
if (state && state !== 'unanswered') return true;
|
|
39
39
|
return 'replied' in interaction && (interaction.replied || interaction.deferred);
|
|
40
40
|
}
|
|
41
|
+
/** How long a message's handler may take, with others waiting after it, before it is named as slow. */ const SLOW_HANDLER_MS = 5000;
|
|
41
42
|
/**
|
|
42
43
|
* Routes interactions, messages and reactions to the handlers that take them, as the bot does, and runs
|
|
43
44
|
* each through its pipeline. The app and the testing module share it, so a test routes exactly as the bot.
|
|
@@ -293,22 +294,64 @@ import { existingResponse } from '../common/response/response-state.js';
|
|
|
293
294
|
message
|
|
294
295
|
], error, this.runOptions(call));
|
|
295
296
|
}
|
|
297
|
+
// The matched handler, then every listener, each resolved from the container only as it starts
|
|
298
|
+
const runs = [];
|
|
296
299
|
if (target) {
|
|
297
300
|
const { route, params, start, given } = target;
|
|
298
301
|
noteInvocation(message, `${start}${commandWordsOf(route.tokens).join(' ')}`);
|
|
299
302
|
const hooks = messageCommandHooks(route, params, message, start, given, this.messageOptions.types);
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
303
|
+
runs.push({
|
|
304
|
+
name: `${route.controllerClass.name}.${route.method}`,
|
|
305
|
+
run: (at)=>this.invokeHandler(this.getInstance(route.controllerClass), route.method, [
|
|
306
|
+
message,
|
|
307
|
+
params
|
|
308
|
+
], at, hooks)
|
|
309
|
+
});
|
|
304
310
|
}
|
|
305
311
|
for (const { controllerClass, method } of this.messageListeners){
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
312
|
+
runs.push({
|
|
313
|
+
name: `${controllerClass.name}.${method}`,
|
|
314
|
+
run: (at)=>this.invokeHandler(this.getInstance(controllerClass), method, [
|
|
315
|
+
message
|
|
316
|
+
], at)
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
if (this.messageOptions.handlers === 'concurrent') await this.runTogether(runs, call);
|
|
320
|
+
else await this.runInTurn(runs, call);
|
|
321
|
+
}
|
|
322
|
+
/** Runs a message's handlers one after another, warning once about one that held the rest back for long. */ async runInTurn(runs, call) {
|
|
323
|
+
const warn = this.messageOptions.slowHandlerWarning ?? this.options.warnSlowMessageHandlers ?? true;
|
|
324
|
+
for (const [index, { name, run }] of runs.entries()){
|
|
325
|
+
const started = performance.now();
|
|
326
|
+
await run(call);
|
|
327
|
+
const waiting = runs.length - 1 - index;
|
|
328
|
+
if (warn && waiting > 0) this.warnSlowOnce(name, performance.now() - started, waiting);
|
|
309
329
|
}
|
|
310
330
|
}
|
|
311
331
|
/**
|
|
332
|
+
* Starts a message's handlers together and settles once all have. Each is reported as settled in the order the
|
|
333
|
+
* handlers are listed, whichever settles first, and a call that rejects, rejects this once every one has settled.
|
|
334
|
+
*/ async runTogether(runs, call) {
|
|
335
|
+
const { record } = call;
|
|
336
|
+
const reports = runs.map(()=>[]);
|
|
337
|
+
const results = await Promise.allSettled(runs.map(({ run }, index)=>run(record ? {
|
|
338
|
+
...call,
|
|
339
|
+
record: {
|
|
340
|
+
settled: (...report)=>reports[index].push(report),
|
|
341
|
+
unhandled: (error)=>record.unhandled(error)
|
|
342
|
+
}
|
|
343
|
+
} : call)));
|
|
344
|
+
for (const report of reports.flat())record?.settled(...report);
|
|
345
|
+
const failed = results.find((result)=>result.status === 'rejected');
|
|
346
|
+
if (failed) throw failed.reason;
|
|
347
|
+
}
|
|
348
|
+
/** Warns, once per handler, that it took `SLOW_HANDLER_MS` or longer with `waiting` handlers held back behind it. */ warnSlowOnce(name, took, waiting) {
|
|
349
|
+
if (took < SLOW_HANDLER_MS || this.warnedSlow.has(name)) return;
|
|
350
|
+
this.warnedSlow.add(name);
|
|
351
|
+
const held = waiting === 1 ? 'the listener after it' : `the ${waiting} listeners after it`;
|
|
352
|
+
this.logger.warn(`${name} took ${(took / 1000).toFixed(1)} s on a message, and held back ${held}. ` + "@MeoCord({ messages: { handlers: 'concurrent' } }) runs them side by side; messages: { slowHandlerWarning: false } turns this off.");
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
312
355
|
* Answers a message that asks the built-in help, when `messages.help` is on and no handler took the message:
|
|
313
356
|
* through the presenter's `messageHelp` when it has one, else in plain text, once the app's guards allow it.
|
|
314
357
|
* `known` is the message's starts when they hold the app's prefixes already. Whether it answered, or a
|
|
@@ -429,6 +472,7 @@ import { existingResponse } from '../common/response/response-state.js';
|
|
|
429
472
|
this.controllerInstancesCache = new Map();
|
|
430
473
|
/** Handler and name pairs already warned about, so a collision warns once, not on every submit or selection. */ this.warnedCollisions = new Set();
|
|
431
474
|
/** The handlers already warned about, so each is named once however often it runs. */ this.warnedUnanswered = new Set();
|
|
475
|
+
/** The message handlers already warned about as slow, so each is named once. */ this.warnedSlow = new Set();
|
|
432
476
|
this.container = options.container;
|
|
433
477
|
this.controllerClasses = options.controllerClasses;
|
|
434
478
|
this.messageOptions = options.messageOptions ?? {};
|
|
@@ -7,7 +7,7 @@ import { themeForInteraction, useTheme } from './theme-scope.js';
|
|
|
7
7
|
import { errorText } from '../common/translate-error.js';
|
|
8
8
|
import { interactionLocale, translatorOfClient, messageLocale, renderText } from '../common/meocord-text.js';
|
|
9
9
|
import { logFailedSend } from '../common/response/send-failure.js';
|
|
10
|
-
import { presenterFor, withSendableFiles, DEFAULT_ATTACHMENT_SIZE_LIMIT, attachmentsOf, renderEmbed, withoutFiles, isTooLarge, REFUSED_AS_TOO_LARGE } from '../common/response/presenter.js';
|
|
10
|
+
import { presenterFor, fitText, TEXT_LIMITS, withSendableFiles, DEFAULT_ATTACHMENT_SIZE_LIMIT, attachmentsOf, renderEmbed, withoutFiles, isTooLarge, REFUSED_AS_TOO_LARGE } from '../common/response/presenter.js';
|
|
11
11
|
import { escapeForLog, quoteForLog } from '../util/user-text.util.js';
|
|
12
12
|
|
|
13
13
|
const UNKNOWN_INTERACTION = 10062;
|
|
@@ -79,7 +79,10 @@ function errorCode(error) {
|
|
|
79
79
|
return send(reply.withoutFiles);
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
-
/** The text of a reply to a message, after the call's `emojis.warning` when `withEmoji
|
|
82
|
+
/** The text of a reply to a message, after the call's `emojis.warning` when `withEmoji`, cut to fit a message's content. */ function replyText(text, withEmoji) {
|
|
83
|
+
const emoji = withEmoji ? `${useTheme().emojis.warning} ` : '';
|
|
84
|
+
return emoji + fitText(text, TEXT_LIMITS.content - emoji.length);
|
|
85
|
+
}
|
|
83
86
|
/**
|
|
84
87
|
* `text`, answering `error` for `message`: plain, after the call's `emojis.warning` when `withEmoji`, unless the
|
|
85
88
|
* presenter's `messageError` draws it as an embed, coloured as an interaction's view is, with that emoji when it has
|
|
@@ -87,28 +90,33 @@ function errorCode(error) {
|
|
|
87
90
|
* the failure comes back with it, for the caller to report once sent.
|
|
88
91
|
*/ async function presentedReply(message, error, text, withEmoji, logger) {
|
|
89
92
|
const presenter = presenterFor(message.client);
|
|
93
|
+
const translator = translatorOfClient(message.client);
|
|
94
|
+
const locale = messageLocale(message) ?? translator?.defaultLocale ?? 'en-US';
|
|
95
|
+
// An empty or whitespace-only message, which Discord refuses, reads as MeoCord's generic error text, so neither the plain reply nor a presenter is left without one
|
|
96
|
+
const shown = text.trim() === '' ? renderText(translator, locale, {
|
|
97
|
+
key: 'meocord.fallback.error'
|
|
98
|
+
}) : text;
|
|
90
99
|
const plain = {
|
|
91
100
|
body: {
|
|
92
|
-
content: replyText(
|
|
101
|
+
content: replyText(shown, withEmoji)
|
|
93
102
|
}
|
|
94
103
|
};
|
|
95
104
|
const { messageError } = presenter;
|
|
96
105
|
if (!messageError) return plain;
|
|
97
106
|
try {
|
|
98
107
|
const theme = await themeForInteraction(message);
|
|
99
|
-
const translator = translatorOfClient(message.client);
|
|
100
|
-
const locale = messageLocale(message) ?? translator?.defaultLocale ?? 'en-US';
|
|
101
108
|
const tone = isUserOutcome(error, message) ? 'warning' : 'danger';
|
|
109
|
+
const presented = {
|
|
110
|
+
message: fitText(shown, TEXT_LIMITS.embed),
|
|
111
|
+
error,
|
|
112
|
+
tone
|
|
113
|
+
};
|
|
102
114
|
const drawn = await messageError.call(presenter, {
|
|
103
115
|
message,
|
|
104
116
|
locale,
|
|
105
117
|
mode: 'embed',
|
|
106
118
|
theme
|
|
107
|
-
},
|
|
108
|
-
message: text,
|
|
109
|
-
error,
|
|
110
|
-
tone
|
|
111
|
-
});
|
|
119
|
+
}, presented);
|
|
112
120
|
let view = drawn.color === undefined ? {
|
|
113
121
|
...drawn,
|
|
114
122
|
color: theme.colors.primary
|
|
@@ -165,7 +173,8 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
|
|
|
165
173
|
await sendReply(reply, (body)=>message.reply({
|
|
166
174
|
...body,
|
|
167
175
|
allowedMentions: {
|
|
168
|
-
repliedUser: false
|
|
176
|
+
repliedUser: false,
|
|
177
|
+
parse: []
|
|
169
178
|
}
|
|
170
179
|
}));
|
|
171
180
|
} catch (replyError) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import 'reflect-metadata';
|
|
2
2
|
import { perHandler, sourcePrototype, stageClasses } from './guard-runner.js';
|
|
3
3
|
import { bindShared } from './interceptor-runner.js';
|
|
4
|
-
import {
|
|
4
|
+
import { startupError } from '../util/refusal.util.js';
|
|
5
5
|
import { isConstructor } from '../util/value.util.js';
|
|
6
6
|
import { META } from '../util/metadata-keys.js';
|
|
7
7
|
|
|
@@ -41,7 +41,7 @@ import { META } from '../util/metadata-keys.js';
|
|
|
41
41
|
/** Binds a filter as a singleton, after checking it is one. */ function prepareFilter(container, entry) {
|
|
42
42
|
const cls = filterClass(entry);
|
|
43
43
|
if (!Reflect.hasOwnMetadata(META.catchTypes, cls)) {
|
|
44
|
-
|
|
44
|
+
return startupError(new Error(`${cls.name || 'A filter'}: used as an exception filter, but not decorated with @Catch().`));
|
|
45
45
|
}
|
|
46
46
|
bindShared(container, cls);
|
|
47
47
|
}
|
|
@@ -338,14 +338,27 @@ const wrapperCount = perHandler((prototype, methodName)=>{
|
|
|
338
338
|
else marks.delete(methodName);
|
|
339
339
|
return true;
|
|
340
340
|
}
|
|
341
|
+
/** The container that made each instance, so a direct call runs in its own app or testing module. */ const instanceContainers = new WeakMap();
|
|
342
|
+
/**
|
|
343
|
+
* Records each instance `container` makes of `classes` as its own. Two testing modules made from one class each run a
|
|
344
|
+
* direct call on their own instance in themselves, where the class's metadata names only the last.
|
|
345
|
+
*/ function recordInstanceContainers(container, classes) {
|
|
346
|
+
for (const cls of classes){
|
|
347
|
+
container.onActivation(cls, (_context, instance)=>{
|
|
348
|
+
if (typeof instance === 'object' && instance !== null) instanceContainers.set(instance, container);
|
|
349
|
+
return instance;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
341
353
|
/**
|
|
342
354
|
* Runs a direct call to a guarded handler: the handler's guards, as dispatch resolves them, then the
|
|
343
355
|
* call with a pass for each wrapper inside the one entered, so no guard runs twice.
|
|
344
356
|
*/ async function runDirectCall(instance, methodName, args, call) {
|
|
345
357
|
const prototype = Object.getPrototypeOf(instance);
|
|
346
358
|
const controller = instance.constructor;
|
|
347
|
-
// The app that made the class
|
|
348
|
-
|
|
359
|
+
// The container that made the instance, or else the app that made the class or a class it extends: a subclass the
|
|
360
|
+
// app's code makes runs as its base does
|
|
361
|
+
const container = instanceContainers.get(instance) ?? Reflect.getMetadata(META.container, controller);
|
|
349
362
|
if (!container) {
|
|
350
363
|
throw new Error(`${controller.name}.${methodName}: @UseGuard runs its guards through the app that made ${controller.name}, and this ` + `instance was made outside one. Inject ${controller.name}, or call it on an instance the app made.`);
|
|
351
364
|
}
|
|
@@ -384,4 +397,4 @@ const wrapperCount = perHandler((prototype, methodName)=>{
|
|
|
384
397
|
}
|
|
385
398
|
}
|
|
386
399
|
|
|
387
|
-
export { callGuardedHandler, classChain, classLevelGuards, consumeDispatchMark, declaringPrototype, handlerGuards, handlerMethods, injectedTokens, isGuardWithParams, perHandler, runDirectCall, runGuards, singletonContextError, sourcePrototype, stageClasses, undecoratedConstructor, untypedParameter };
|
|
400
|
+
export { callGuardedHandler, classChain, classLevelGuards, consumeDispatchMark, declaringPrototype, handlerGuards, handlerMethods, injectedTokens, isGuardWithParams, perHandler, recordInstanceContainers, runDirectCall, runGuards, singletonContextError, sourcePrototype, stageClasses, undecoratedConstructor, untypedParameter };
|
|
@@ -2,6 +2,7 @@ import { ModalSubmitInteraction, ComponentType, StringSelectMenuInteraction, Use
|
|
|
2
2
|
import { getCommandMap } from '../decorator/controller.decorator.js';
|
|
3
3
|
import { matchComponentRoute } from './component-routes.js';
|
|
4
4
|
import { hasCustomId, isCustomIdRouted, matchesHandler, resolveOptionParams, matchesCommandType } from '../util/interaction.util.js';
|
|
5
|
+
import { setOwn } from '../util/value.util.js';
|
|
5
6
|
import { commandRouteKeys, matchCommandRoute } from './command-routes.js';
|
|
6
7
|
|
|
7
8
|
/** The entries of a discord.js Collection, or none for anything else, such as a test double's stub. */ const entriesOf = (collection)=>collection instanceof Map ? [
|
|
@@ -18,8 +19,8 @@ import { commandRouteKeys, matchCommandRoute } from './command-routes.js';
|
|
|
18
19
|
for (const [customId, field] of fields){
|
|
19
20
|
const data = field;
|
|
20
21
|
// An upload's `values` are the attachments' ids; discord.js resolves the attachments themselves alongside
|
|
21
|
-
if (data.type === ComponentType.FileUpload) values
|
|
22
|
-
else values
|
|
22
|
+
if (data.type === ComponentType.FileUpload) setOwn(values, customId, entriesOf(interaction.fields.getUploadedFiles(customId)));
|
|
23
|
+
else setOwn(values, customId, 'value' in data ? data.value : data.values);
|
|
23
24
|
}
|
|
24
25
|
return values;
|
|
25
26
|
}
|
|
@@ -78,7 +79,7 @@ import { commandRouteKeys, matchCommandRoute } from './command-routes.js';
|
|
|
78
79
|
collisions: []
|
|
79
80
|
};
|
|
80
81
|
const fields = interaction instanceof ModalSubmitInteraction ? modalFields(interaction) : selectChoices(interaction);
|
|
81
|
-
const collisions = Object.keys(routeParams).filter((name)=>name
|
|
82
|
+
const collisions = Object.keys(routeParams).filter((name)=>Object.hasOwn(fields, name));
|
|
82
83
|
return {
|
|
83
84
|
params: {
|
|
84
85
|
...fields,
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import 'reflect-metadata';
|
|
2
|
-
import { refuse } from '../util/refusal.util.js';
|
|
2
|
+
import { refuse, startupError } from '../util/refusal.util.js';
|
|
3
3
|
import { setPresenter } from '../common/response/presenter.js';
|
|
4
4
|
import { responseOf } from '../common/response/response-state.js';
|
|
5
5
|
import { handlerDefer, nonInteractionHandler, deferMisuseError, startDefer } from './defer.js';
|
|
6
|
-
import { perHandler,
|
|
7
|
-
import { prepareInterceptor, handlerInterceptors, bindShared,
|
|
6
|
+
import { perHandler, runGuards, callGuardedHandler, handlerGuards, isGuardWithParams } from './guard-runner.js';
|
|
7
|
+
import { prepareInterceptor, handlerInterceptors, bindShared, runInterceptors, interceptorClass } from './interceptor-runner.js';
|
|
8
8
|
import { inferContextType, HandlerExecutionContext, UnroutedExecutionContext } from '../common/execution-context.js';
|
|
9
9
|
import { prepareFilter, handlerFilterLevels, filterClass, matchFilter, callFilter } from './filter-runner.js';
|
|
10
|
-
import { handlerInputStages, preparePipe,
|
|
10
|
+
import { handlerInputStages, preparePipe, prepareHandlerArgs, pipeClass } from './input-runner.js';
|
|
11
11
|
import { hasObservers, notifyStart, outcomeOf, responsePhaseOf, notifyObservers } from './observer-runner.js';
|
|
12
|
-
import {
|
|
12
|
+
import { handlerCooldowns, peekCooldowns, methodCooldowns } from './cooldown-runner.js';
|
|
13
13
|
import { Logger } from '../common/logger.js';
|
|
14
|
-
import {
|
|
14
|
+
import { beginCallTheme, configureThemes } from './theme-runtime.js';
|
|
15
15
|
import { runInThemeScope } from './theme-scope.js';
|
|
16
16
|
import { getEventHandlers } from '../decorator/event.decorator.js';
|
|
17
17
|
import { getCommandMap, getMessageHandlers, getReactionHandlers, getAutocompleteHandlers } from '../decorator/controller.decorator.js';
|
|
@@ -57,12 +57,16 @@ const NO_GLOBAL_STAGES = {
|
|
|
57
57
|
*/ function bindAppPresenter(container, app, client) {
|
|
58
58
|
const options = Reflect.getMetadata(META.appOptions, app);
|
|
59
59
|
if (!options?.presenter) return undefined;
|
|
60
|
-
|
|
60
|
+
prepareAppPresenter(container, app);
|
|
61
61
|
const presenter = container.get(options.presenter);
|
|
62
62
|
container.bind(APP_PRESENTER).toConstantValue(presenter);
|
|
63
63
|
if (client) setPresenter(client, presenter);
|
|
64
64
|
return presenter;
|
|
65
65
|
}
|
|
66
|
+
/** Binds the app's presenter class as a shared stage, checking it as one, without making it. */ function prepareAppPresenter(container, app) {
|
|
67
|
+
const options = Reflect.getMetadata(META.appOptions, app);
|
|
68
|
+
if (options?.presenter) bindShared(container, options.presenter);
|
|
69
|
+
}
|
|
66
70
|
/** Where a container keeps the application's presenter. */ const APP_PRESENTER = Symbol('app_presenter');
|
|
67
71
|
/** The presenter `bindAppPresenter` resolved into `container`, if any. */ function appPresenterOf(container) {
|
|
68
72
|
return container.isBound(APP_PRESENTER) ? container.get(APP_PRESENTER) : undefined;
|
|
@@ -152,18 +156,21 @@ const stagesByGlobals = new WeakMap();
|
|
|
152
156
|
/**
|
|
153
157
|
* Binds the interceptors, filters and pipes every handler of `controllers` uses, and the global ones, as singletons, so
|
|
154
158
|
* one that cannot be shared, or a filter without `@Catch`, fails at startup; refuses there `@Defer`, `@Validate`,
|
|
155
|
-
* `@UsePipe` and `@Cooldown` where they cannot apply, and two classes that keep state under one name.
|
|
159
|
+
* `@UsePipe` and `@Cooldown` where they cannot apply, and two classes that keep state under one name. Binds only; it
|
|
160
|
+
* makes nothing, so it runs with the other startup checks.
|
|
156
161
|
*/ function prepareHandlerStages(container, controllers) {
|
|
157
162
|
assertDistinctNamesWhereKeyed(controllers);
|
|
158
163
|
const globals = globalStagesOf(container);
|
|
159
|
-
configureThemes(container, globals.theme, controllers, globals.themeFor);
|
|
160
164
|
for (const entry of globals.interceptors)prepareInterceptor(container, entry);
|
|
161
165
|
for (const entry of globals.filters)prepareFilter(container, entry);
|
|
162
166
|
for (const controller of controllers){
|
|
163
167
|
const prototype = controller.prototype;
|
|
164
168
|
for (const method of handlerMethods(prototype)){
|
|
165
169
|
const kind = handlerDefer(prototype, method) ? nonInteractionHandler(prototype, method) : undefined;
|
|
166
|
-
if (kind)
|
|
170
|
+
if (kind) {
|
|
171
|
+
startupError(deferMisuseError(controller.name, method, kind));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
167
174
|
for (const entry of handlerInterceptors(prototype, method))prepareInterceptor(container, entry);
|
|
168
175
|
for (const entry of handlerFilterLevels(prototype, method, []).flat())prepareFilter(container, entry);
|
|
169
176
|
for (const { entry } of handlerInputStages(prototype, method).pipes)preparePipe(container, entry);
|
|
@@ -171,6 +178,10 @@ const stagesByGlobals = new WeakMap();
|
|
|
171
178
|
assertInputStagesOnInteractions(controller, prototype);
|
|
172
179
|
}
|
|
173
180
|
}
|
|
181
|
+
/** Records the app's theme for the handlers of `controllers`, once the startup checks have passed: it makes the theme cache. */ function prepareThemes(container, controllers) {
|
|
182
|
+
const globals = globalStagesOf(container);
|
|
183
|
+
configureThemes(container, globals.theme, controllers, globals.themeFor);
|
|
184
|
+
}
|
|
174
185
|
/** Whether a class has state keyed by its name: a cooldown on a handler, or a `@Once` handler. */ function keyedByName(cls) {
|
|
175
186
|
const prototype = cls.prototype;
|
|
176
187
|
const handlers = [
|
|
@@ -187,7 +198,8 @@ const stagesByGlobals = new WeakMap();
|
|
|
187
198
|
for (const cls of classes){
|
|
188
199
|
const other = byName.get(cls.name);
|
|
189
200
|
if (other && other !== cls && (keyedByName(cls) || keyedByName(other))) {
|
|
190
|
-
|
|
201
|
+
startupError(new Error(`${cls.name}: two classes have this name; @Cooldown and @Once tell classes apart by name, so they would share ` + `their counts. Rename one of them.`));
|
|
202
|
+
continue;
|
|
191
203
|
}
|
|
192
204
|
byName.set(cls.name, cls);
|
|
193
205
|
}
|
|
@@ -215,11 +227,12 @@ const stagesByGlobals = new WeakMap();
|
|
|
215
227
|
const { schema, pipes } = handlerInputStages(prototype, method);
|
|
216
228
|
const handler = kind === 'message' ? 'a message handler without a pattern' : `${kind === 'autocomplete' || kind === 'event' ? 'an' : 'a'} ${kind} handler`;
|
|
217
229
|
if (schema || pipes.length > 0) {
|
|
218
|
-
|
|
230
|
+
startupError(new Error(`${controller.name}.${method}: @Validate and @UsePipe are for interaction and patterned message handlers, ` + `whose options, customId params, modal fields and pattern params they check, and this is ${handler}.`));
|
|
231
|
+
continue;
|
|
219
232
|
}
|
|
220
233
|
// A controller's own @Cooldown skips these handlers; one on the method itself is a mistake.
|
|
221
234
|
if (kind !== 'message' && methodCooldowns(prototype, method).length > 0) {
|
|
222
|
-
|
|
235
|
+
startupError(new Error(`${controller.name}.${method}: @Cooldown is for interaction and message handlers, and this is ${handler}.`));
|
|
223
236
|
}
|
|
224
237
|
}
|
|
225
238
|
}
|
|
@@ -462,4 +475,4 @@ const logger = new Logger('ExceptionFilter');
|
|
|
462
475
|
}, options);
|
|
463
476
|
}
|
|
464
477
|
|
|
465
|
-
export { appPresenterOf, appStages, bindAppPresenter, bindGlobalStages, classDecorators, globalStagesOf, handleUnroutedError, handlerStageClasses, handlerStages, observeUnclaimed, prepareHandlerStages, runHandler, runInAppTheme };
|
|
478
|
+
export { appPresenterOf, appStages, bindAppPresenter, bindGlobalStages, classDecorators, globalStagesOf, handleUnroutedError, handlerStageClasses, handlerStages, observeUnclaimed, prepareAppPresenter, prepareHandlerStages, prepareThemes, runHandler, runInAppTheme };
|
|
@@ -97,7 +97,7 @@ const COMMAND_TYPES = new Set([
|
|
|
97
97
|
*
|
|
98
98
|
* @group Controllers
|
|
99
99
|
* @see {@link HandlerEntry}
|
|
100
|
-
* @see {@link https://meocord.dev/docs/4.
|
|
100
|
+
* @see {@link https://meocord.dev/docs/4.2/handler-discovery | Handler discovery}
|
|
101
101
|
*/ class HandlerRegistry {
|
|
102
102
|
/**
|
|
103
103
|
* Lists the registered handlers.
|
|
@@ -4,7 +4,7 @@ import { perHandler, injectedTokens, singletonContextError, sourcePrototype, sta
|
|
|
4
4
|
import { ExecutionContext } from '../common/execution-context.js';
|
|
5
5
|
import { makeInjectable } from '../util/injectable.util.js';
|
|
6
6
|
import { isAppClassToken } from './lifecycle-order.js';
|
|
7
|
-
import {
|
|
7
|
+
import { startupError } from '../util/refusal.util.js';
|
|
8
8
|
import { Logger } from '../common/logger.js';
|
|
9
9
|
import { META } from '../util/metadata-keys.js';
|
|
10
10
|
|
|
@@ -35,7 +35,7 @@ function isInterceptorWithParams(entry) {
|
|
|
35
35
|
});
|
|
36
36
|
/** Binds `cls` and its unbound dependencies as singletons, refusing any that injects `ExecutionContext`. */ function bindShared(container, cls) {
|
|
37
37
|
if (container.isBound(cls)) return;
|
|
38
|
-
if (injectedTokens(cls).includes(ExecutionContext))
|
|
38
|
+
if (injectedTokens(cls).includes(ExecutionContext)) return startupError(singletonContextError(cls));
|
|
39
39
|
assertStartupChecked(container, cls);
|
|
40
40
|
makeInjectable(cls);
|
|
41
41
|
container.bind(cls).toSelf().inSingletonScope();
|