meocord 4.2.1 → 4.2.2
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 +58 -0
- package/dist/cjs/_shared/{controller.decorator-XKMlcUZz.cjs → controller.decorator-U2NBWcoU.cjs} +45 -13
- package/dist/cjs/_shared/{providers-BZgJyfZS.cjs → providers-B4MOFBrW.cjs} +58 -15
- package/dist/cjs/_shared/{sharded-cooldown-store-CsJJyCZd.cjs → sharded-cooldown-store-HTUb3Ii_.cjs} +1 -1
- package/dist/cjs/_shared/{startup-roots-CgfPQrNl.cjs → startup-roots-lA5xkBFq.cjs} +12 -11
- package/dist/cjs/_shared/{translate-error-BUAR1WYG.cjs → translate-error-CbBHUu5P.cjs} +1 -1
- package/dist/cjs/common/index.cjs +3 -3
- package/dist/cjs/core/index.cjs +8 -6
- package/dist/cjs/decorator/index.cjs +33 -3
- package/dist/cjs/testing/index.cjs +295 -60
- package/dist/cli.json +1 -1
- package/dist/esm/common/response/response-state.js +5 -5
- package/dist/esm/common/translator.js +15 -5
- package/dist/esm/core/dispatcher.js +9 -8
- package/dist/esm/core/filter-runner.js +2 -2
- package/dist/esm/core/handler-pipeline.js +10 -4
- package/dist/esm/core/meocord-factory.js +4 -2
- package/dist/esm/core/message-help.js +32 -6
- package/dist/esm/core/observer-runner.js +2 -1
- package/dist/esm/core/providers.js +12 -2
- package/dist/esm/core/stage-scope.js +2 -1
- package/dist/esm/core/theme-scope.js +4 -1
- package/dist/esm/decorator/app.decorator.js +31 -0
- package/dist/esm/decorator/filter.decorator.js +2 -2
- package/dist/esm/package.json.js +1 -1
- package/dist/esm/testing/catalog.js +4 -13
- package/dist/esm/testing/discord-defaults.js +21 -1
- package/dist/esm/testing/meocord-testing-module.js +24 -16
- package/dist/esm/testing/mock-interaction.js +242 -28
- package/dist/esm/testing/strict-mocks.js +7 -4
- package/dist/esm/util/value.util.js +19 -2
- package/dist/types/common/index.d.cts +4 -1
- package/dist/types/common/index.d.ts +4 -1
- package/dist/types/testing/index.d.cts +28 -14
- package/dist/types/testing/index.d.ts +28 -14
- package/package.json +1 -1
|
@@ -69,16 +69,26 @@ import { sharedName } from '../util/shared-state.util.js';
|
|
|
69
69
|
}
|
|
70
70
|
const DISCORD_LOCALES = new Set(Object.values(Locale));
|
|
71
71
|
const languageOf = (locale)=>locale.split('-')[0];
|
|
72
|
-
|
|
72
|
+
const PLURAL_CATEGORIES = new Set([
|
|
73
|
+
'zero',
|
|
74
|
+
'one',
|
|
75
|
+
'two',
|
|
76
|
+
'few',
|
|
77
|
+
'many',
|
|
78
|
+
'other'
|
|
79
|
+
]);
|
|
80
|
+
/** A plural's shape: a non-empty object whose every key is a plural category, as the types read one; any other is a group. */ const isPluralShape = (value)=>typeof value === 'object' && value !== null && Object.keys(value).length > 0 && Object.keys(value).every((key)=>PLURAL_CATEGORIES.has(key));
|
|
81
|
+
/**
|
|
82
|
+
* A message by its dotted key, or undefined when the catalog lacks it or the key names a group. A plural without an
|
|
83
|
+
* `other` form reads as missing, so a translation that left it out falls back to the next catalog.
|
|
84
|
+
*/ function lookup(catalog, key) {
|
|
73
85
|
let current = catalog;
|
|
74
86
|
for (const part of key.split('.')){
|
|
75
87
|
if (typeof current !== 'object' || current === null) return undefined;
|
|
76
88
|
current = current[part];
|
|
77
89
|
}
|
|
78
90
|
if (typeof current === 'string') return current;
|
|
79
|
-
if (
|
|
80
|
-
return current;
|
|
81
|
-
}
|
|
91
|
+
if (isPluralShape(current) && typeof current.other === 'string') return current;
|
|
82
92
|
return undefined;
|
|
83
93
|
}
|
|
84
94
|
const plurals = new Map();
|
|
@@ -278,4 +288,4 @@ class CatalogTranslator extends Translator {
|
|
|
278
288
|
return new CatalogTranslator(defaultLocale, locales);
|
|
279
289
|
}
|
|
280
290
|
|
|
281
|
-
export { CATALOGS, LOCALE_CHAIN, Translator, createTranslator, defineCatalog, fillPlaceholders, languageOf, lookup, missingTranslatorError, placeholderNames, pluralForm };
|
|
291
|
+
export { CATALOGS, LOCALE_CHAIN, Translator, createTranslator, defineCatalog, fillPlaceholders, isPluralShape, languageOf, lookup, missingTranslatorError, placeholderNames, pluralForm };
|
|
@@ -241,10 +241,7 @@ import { existingResponse } from '../common/response/response-state.js';
|
|
|
241
241
|
* says whether the handler ran. Its guard wrappers let this call through, so each guard runs once.
|
|
242
242
|
*/ async invokeHandler(instance, methodName, args, call, hooks = {}) {
|
|
243
243
|
const handler = `${instance.constructor.name}.${methodName}`;
|
|
244
|
-
const onUnanswered = this.options.warnUnanswered ? (
|
|
245
|
-
name: returnedBy.interceptor.name,
|
|
246
|
-
handlerStarted: returnedBy.handlerStarted
|
|
247
|
-
}) : undefined;
|
|
244
|
+
const onUnanswered = this.options.warnUnanswered ? (unanswered)=>this.warnUnansweredOnce(handler, unanswered) : undefined;
|
|
248
245
|
const outcome = await runHandler(this.container, instance, methodName, args, {
|
|
249
246
|
...this.runOptions(call),
|
|
250
247
|
onUnanswered,
|
|
@@ -254,13 +251,17 @@ import { existingResponse } from '../common/response/response-state.js';
|
|
|
254
251
|
return outcome.ran;
|
|
255
252
|
}
|
|
256
253
|
/**
|
|
257
|
-
* Warns, once per handler, that it left its interaction unanswered or deferred without a follow-up,
|
|
258
|
-
*
|
|
259
|
-
*/ warnUnansweredOnce(handler, phase,
|
|
254
|
+
* Warns, once per handler, that it left its interaction or autocomplete unanswered or deferred without a follow-up,
|
|
255
|
+
* or that an interceptor returned before the handler ran or finished, and the call ended so.
|
|
256
|
+
*/ warnUnansweredOnce(handler, { type, phase, returnedBy }) {
|
|
260
257
|
if (this.warnedUnanswered.has(handler)) return;
|
|
261
258
|
this.warnedUnanswered.add(handler);
|
|
259
|
+
const interceptor = returnedBy && {
|
|
260
|
+
name: returnedBy.interceptor.name,
|
|
261
|
+
handlerStarted: returnedBy.handlerStarted
|
|
262
|
+
};
|
|
262
263
|
const returned = interceptor && `${handler}: its interceptor ${interceptor.name} returned before the handler ${interceptor.handlerStarted ? 'finished' : 'ran'}`;
|
|
263
|
-
const what = interceptor ? phase === 'unanswered' ? `${returned}, without answering the interaction, so the user saw "The application did not respond". Answer it ` + `in ${interceptor.name}, or await next.handle().` : interceptor.handlerStarted ? `${returned}, and the deferred interaction had no follow-up when the call ended, so @Defer's lock was ` + `released while the user still saw it thinking. Follow up in ${interceptor.name} with respond(interaction).send(), ` + 'or await next.handle().' : `${returned}, and the deferred interaction was never followed up, so the user saw it thinking until Discord ` + `gave up. Follow up in ${interceptor.name} with respond(interaction).send(), or await next.handle().` : phase === 'unanswered' ? `${handler} finished without answering its interaction, so the user saw "The application did not respond". ` + 'Answer it with respond(interaction).send(), or acknowledge it first with @Defer().' : `${handler} deferred its interaction and never followed up, so the user saw it thinking until Discord gave up. ` + 'Follow up with respond(interaction).send().';
|
|
264
|
+
const what = type === 'autocomplete' ? `${handler} finished without answering its autocomplete, so the user saw its options fail to load. Answer it ` + 'with interaction.respond(choices) within 3 seconds.' : interceptor ? phase === 'unanswered' ? `${returned}, without answering the interaction, so the user saw "The application did not respond". Answer it ` + `in ${interceptor.name}, or await next.handle().` : interceptor.handlerStarted ? `${returned}, and the deferred interaction had no follow-up when the call ended, so @Defer's lock was ` + `released while the user still saw it thinking. Follow up in ${interceptor.name} with respond(interaction).send(), ` + 'or await next.handle().' : `${returned}, and the deferred interaction was never followed up, so the user saw it thinking until Discord ` + `gave up. Follow up in ${interceptor.name} with respond(interaction).send(), or await next.handle().` : phase === 'unanswered' ? `${handler} finished without answering its interaction, so the user saw "The application did not respond". ` + 'Answer it with respond(interaction).send(), or acknowledge it first with @Defer().' : `${handler} deferred its interaction and never followed up, so the user saw it thinking until Discord gave up. ` + 'Follow up with respond(interaction).send().';
|
|
264
265
|
this.logger.warn(`${what} Shown once per handler; @MeoCord({ warnUnanswered: false }) turns it off.`);
|
|
265
266
|
}
|
|
266
267
|
/**
|
|
@@ -2,7 +2,7 @@ import 'reflect-metadata';
|
|
|
2
2
|
import { perHandler, sourcePrototype, stageClasses } from './guard-runner.js';
|
|
3
3
|
import { bindShared } from './interceptor-runner.js';
|
|
4
4
|
import { startupError } from '../util/refusal.util.js';
|
|
5
|
-
import {
|
|
5
|
+
import { isConstructible } from '../util/value.util.js';
|
|
6
6
|
import { META } from '../util/metadata-keys.js';
|
|
7
7
|
|
|
8
8
|
/** The filter class an entry names. */ function filterClass(entry) {
|
|
@@ -51,7 +51,7 @@ import { META } from '../util/metadata-keys.js';
|
|
|
51
51
|
// Every filter reaching here was checked for @Catch at startup, so its types are always recorded
|
|
52
52
|
const types = Reflect.getOwnMetadata(META.catchTypes, filterClass(entry));
|
|
53
53
|
// Only classes match: anything else given to @Catch, warned of as it applied, matches no error
|
|
54
|
-
if (types.length === 0 || types.some((type)=>
|
|
54
|
+
if (types.length === 0 || types.some((type)=>isConstructible(type) && error instanceof type)) return entry;
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
return undefined;
|
|
@@ -343,7 +343,9 @@ const logger = new Logger('ExceptionFilter');
|
|
|
343
343
|
denial
|
|
344
344
|
})) {
|
|
345
345
|
outcome = 'denied';
|
|
346
|
-
|
|
346
|
+
// Without @Defer too, so a denied component is acknowledged rather than left to fail
|
|
347
|
+
const denied = response ?? (type === 'interaction' && first?.isRepliable?.() ? responseOf(first) : undefined);
|
|
348
|
+
await denied?.abandon();
|
|
347
349
|
return {
|
|
348
350
|
ran: false
|
|
349
351
|
};
|
|
@@ -399,14 +401,18 @@ const logger = new Logger('ExceptionFilter');
|
|
|
399
401
|
};
|
|
400
402
|
} finally{
|
|
401
403
|
await response?.release();
|
|
402
|
-
if (options.onUnanswered && (ran || returnedEarly) && outcome === 'ran' && type === 'interaction') {
|
|
404
|
+
if (options.onUnanswered && (ran || returnedEarly) && outcome === 'ran' && (type === 'interaction' || type === 'autocomplete')) {
|
|
403
405
|
const phase = responsePhaseOf(contextOf());
|
|
404
406
|
// The handler is to blame only once it has finished; until then, the interceptor that ended the call is
|
|
405
|
-
const
|
|
407
|
+
const returnedBy = !finished && returnedEarly ? {
|
|
406
408
|
interceptor: returnedEarly,
|
|
407
409
|
handlerStarted: ran
|
|
408
410
|
} : undefined;
|
|
409
|
-
if (phase === 'unanswered' || phase === 'deferred') options.onUnanswered(
|
|
411
|
+
if (phase === 'unanswered' || phase === 'deferred') options.onUnanswered({
|
|
412
|
+
type,
|
|
413
|
+
phase,
|
|
414
|
+
returnedBy
|
|
415
|
+
});
|
|
410
416
|
}
|
|
411
417
|
if (options.awaitObservers) await starting;
|
|
412
418
|
// After the answer and the release, so the duration covers the whole call
|
|
@@ -32,7 +32,7 @@ import { meocordClasses } from './meocord-classes.js';
|
|
|
32
32
|
import { buildMessageRoutes } from './message-routes.js';
|
|
33
33
|
import { appObservers, bindObservers } from './observer-runner.js';
|
|
34
34
|
import { isAppClassToken } from './lifecycle-order.js';
|
|
35
|
-
import { reachableClasses, providerMap, bindsOwnToken, tokenName, bindProvider, resolutionOrder, isClassProvider, assertProvided, tokenDependencies, resolveProviders } from './providers.js';
|
|
35
|
+
import { reachableClasses, providerMap, assertProvidedServices, bindsOwnToken, tokenName, bindProvider, resolutionOrder, isClassProvider, assertProvided, tokenDependencies, resolveProviders } from './providers.js';
|
|
36
36
|
import { HandlerRegistry } from './handler-registry.js';
|
|
37
37
|
import { ShardManager } from './shard-manager.js';
|
|
38
38
|
import { warnUnregisteredCommands, warnInheritedRoutes, warnOverlappingPatterns, warnHandlersOffControllers, assertDistinctCommands } from './command-conflicts.js';
|
|
@@ -176,7 +176,9 @@ import { isShardProcess } from '../util/shard-process.util.js';
|
|
|
176
176
|
const providers = collectStartupErrors(()=>{
|
|
177
177
|
declared.forEach(startupError);
|
|
178
178
|
assertDistinctCommands(options.controllers);
|
|
179
|
-
|
|
179
|
+
const providers = providerMap(options.providers ?? [], '@MeoCord({ providers })');
|
|
180
|
+
assertProvidedServices(typeof target === 'function' ? target.name : String(target), options.services ?? [], providers, '@MeoCord({ providers })');
|
|
181
|
+
return providers;
|
|
180
182
|
});
|
|
181
183
|
// A themeFor class is bound on its own, as the cooldown store is, and resolved like a service
|
|
182
184
|
const themeResolver = themeResolverClass(options.themeFor);
|
|
@@ -249,22 +249,48 @@ const sortedEntries = (entries)=>entries.sort((a, b)=>a.usage < b.usage ? -1 : a
|
|
|
249
249
|
scope
|
|
250
250
|
].filter((line)=>line !== undefined).join('\n');
|
|
251
251
|
}
|
|
252
|
-
|
|
252
|
+
// Built on first use, for a line longer than a message
|
|
253
|
+
let graphemes;
|
|
254
|
+
/**
|
|
255
|
+
* Where a line longer than one message is cut: after the last character a reader sees as one, such as an emoji, a flag
|
|
256
|
+
* or a letter with its accent, that still fits. A single such character longer than a message is cut between code points.
|
|
257
|
+
*/ function fittingCut(piece) {
|
|
258
|
+
let cut = 0;
|
|
259
|
+
graphemes ?? (graphemes = new Intl.Segmenter(undefined, {
|
|
260
|
+
granularity: 'grapheme'
|
|
261
|
+
}));
|
|
262
|
+
for (const { index, segment } of graphemes.segment(piece)){
|
|
263
|
+
if (index + segment.length > MAX_REPLY_LENGTH) break;
|
|
264
|
+
cut = index + segment.length;
|
|
265
|
+
}
|
|
266
|
+
if (cut > 0) return cut;
|
|
267
|
+
const high = piece.charCodeAt(MAX_REPLY_LENGTH - 1);
|
|
268
|
+
return high >= 0xd800 && high <= 0xdbff ? MAX_REPLY_LENGTH - 1 : MAX_REPLY_LENGTH;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Splits a reply into Discord-sized messages at line breaks, a line longer than one message cut where it must be. A
|
|
272
|
+
* message of only whitespace, which Discord refuses as empty, is left out.
|
|
273
|
+
*/ function splitReply(text) {
|
|
253
274
|
const chunks = [];
|
|
275
|
+
const push = (chunk)=>{
|
|
276
|
+
if (chunk.trim() !== '') chunks.push(chunk);
|
|
277
|
+
};
|
|
254
278
|
let current = '';
|
|
255
279
|
for (const line of text.split('\n')){
|
|
256
|
-
for(let piece = line;;
|
|
257
|
-
const
|
|
280
|
+
for(let piece = line;;){
|
|
281
|
+
const cut = piece.length <= MAX_REPLY_LENGTH ? piece.length : fittingCut(piece);
|
|
282
|
+
const part = piece.slice(0, cut);
|
|
258
283
|
const joined = current ? `${current}\n${part}` : part;
|
|
259
284
|
if (joined.length <= MAX_REPLY_LENGTH) current = joined;
|
|
260
285
|
else {
|
|
261
|
-
|
|
286
|
+
push(current);
|
|
262
287
|
current = part;
|
|
263
288
|
}
|
|
264
|
-
if (piece.length
|
|
289
|
+
if (cut === piece.length) break;
|
|
290
|
+
piece = piece.slice(cut);
|
|
265
291
|
}
|
|
266
292
|
}
|
|
267
|
-
|
|
293
|
+
push(current);
|
|
268
294
|
return chunks;
|
|
269
295
|
}
|
|
270
296
|
|
|
@@ -5,6 +5,7 @@ import { bindShared } from './interceptor-runner.js';
|
|
|
5
5
|
import { appliesTo } from './stage-scope.js';
|
|
6
6
|
import { respond } from '../common/response/response-state.js';
|
|
7
7
|
import { startupError } from '../util/refusal.util.js';
|
|
8
|
+
import { isConstructor } from '../util/value.util.js';
|
|
8
9
|
import { META } from '../util/metadata-keys.js';
|
|
9
10
|
|
|
10
11
|
/** Where a container keeps the observers its calls are reported to, in order. */ const OBSERVERS = Symbol('observers');
|
|
@@ -12,7 +13,7 @@ const logger = new Logger('Observer');
|
|
|
12
13
|
/** Reports each entry that is not an `@Observer` class, naming where it was listed. */ function assertObservers(where, observers) {
|
|
13
14
|
for (const observer of observers){
|
|
14
15
|
if (typeof observer !== 'function' || !Reflect.getMetadata(META.observerClass, observer)) {
|
|
15
|
-
const name = typeof observer === 'function' ?
|
|
16
|
+
const name = isConstructor(observer) ? observer.name || 'an anonymous class' : typeof observer === 'function' ? 'a function' : String(observer);
|
|
16
17
|
startupError(new Error(`${where} takes classes decorated with @Observer(), not ${name}.`));
|
|
17
18
|
continue;
|
|
18
19
|
}
|
|
@@ -4,6 +4,7 @@ import { meocordClasses, meocordClassAdvice } from './meocord-classes.js';
|
|
|
4
4
|
import { isAppClassToken } from './lifecycle-order.js';
|
|
5
5
|
import { makeInjectable } from '../util/injectable.util.js';
|
|
6
6
|
import { startupError, refuse } from '../util/refusal.util.js';
|
|
7
|
+
import { isConstructor } from '../util/value.util.js';
|
|
7
8
|
|
|
8
9
|
const isValueProvider = (provider)=>'useValue' in provider;
|
|
9
10
|
const isClassProvider = (provider)=>'useClass' in provider;
|
|
@@ -43,7 +44,7 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
|
|
|
43
44
|
startupError(new Error(`${where}: the provider for ${name} needs exactly one of useValue, useClass and useFactory.`));
|
|
44
45
|
continue;
|
|
45
46
|
}
|
|
46
|
-
if ('useClass' in entry &&
|
|
47
|
+
if ('useClass' in entry && !isConstructor(entry.useClass)) {
|
|
47
48
|
startupError(new Error(`${where}: the provider for ${name} has a useClass that is not a class.`));
|
|
48
49
|
continue;
|
|
49
50
|
}
|
|
@@ -120,6 +121,15 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
|
|
|
120
121
|
});
|
|
121
122
|
for (const dependency of inject)if (isAppClassToken(dependency)) bindClass(dependency);
|
|
122
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Reports each service listed by a string or symbol token that no provider provides, which would otherwise be bound
|
|
126
|
+
* as if it were a class.
|
|
127
|
+
*/ function assertProvidedServices(appName, services, providers, where) {
|
|
128
|
+
for (const service of services){
|
|
129
|
+
if (typeof service === 'function' || providers.has(service)) continue;
|
|
130
|
+
startupError(new Error(`${appName}: @MeoCord({ services }) lists ${tokenName(service)}, which nothing provides: add a provider for it to ${where}.`));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
123
133
|
/**
|
|
124
134
|
* Reports a class or a factory that asks for a string or symbol token nothing binds, naming both, rather than failing
|
|
125
135
|
* when the class is first resolved. A class token is bound on demand, so it is never missing.
|
|
@@ -271,4 +281,4 @@ const isToken = (value)=>typeof value === 'function' || typeof value === 'string
|
|
|
271
281
|
}
|
|
272
282
|
}
|
|
273
283
|
|
|
274
|
-
export { assertProvided, assertTypedParameters, bindProvider, bindsOwnToken, isClassProvider, isFactoryProvider, isValueProvider, providerFailure, providerMap, reachableClasses, resolutionOrder, resolveProviders, tokenDependencies, tokenName };
|
|
284
|
+
export { assertProvided, assertProvidedServices, assertTypedParameters, bindProvider, bindsOwnToken, isClassProvider, isFactoryProvider, isValueProvider, providerFailure, providerMap, reachableClasses, resolutionOrder, resolveProviders, tokenDependencies, tokenName };
|
|
@@ -46,7 +46,8 @@ import { META } from '../util/metadata-keys.js';
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
/** The class of a stage entry. */ function stageClass(entry) {
|
|
49
|
-
|
|
49
|
+
// Entries were checked as they were declared, so a function is the class itself
|
|
50
|
+
return typeof entry === 'function' ? entry : entry.provide;
|
|
50
51
|
}
|
|
51
52
|
/** The context types a stage declared, or `undefined` when it runs for every type. */ function stageTypes(entry) {
|
|
52
53
|
return Reflect.getMetadata(META.stageTypes, stageClass(entry));
|
|
@@ -172,6 +172,9 @@ let themeOutsideCalls;
|
|
|
172
172
|
* Such a listener runs in its emitter's context, so without it a `useTheme()` inside reads the theme outside any call.
|
|
173
173
|
* Timers and promises the handler starts keep its theme without it.
|
|
174
174
|
*
|
|
175
|
+
* The theme is the binding call's whole theme, its server's and user's themes included, so every later call, whoever
|
|
176
|
+
* it is for, is answered in the starting member's theme.
|
|
177
|
+
*
|
|
175
178
|
* @param fn - The function to run in the call's theme; `this` and its arguments are passed through.
|
|
176
179
|
* @returns A function that runs `fn` in the theme `useTheme()` returns where `bindTheme` is called.
|
|
177
180
|
*
|
|
@@ -181,7 +184,7 @@ let themeOutsideCalls;
|
|
|
181
184
|
* async poll(message: Message) {
|
|
182
185
|
* const collector = message.createMessageComponentCollector({ componentType: ComponentType.Button, time: 60_000 })
|
|
183
186
|
* collector.on('collect', bindTheme(async (click: ButtonInteraction) => {
|
|
184
|
-
* const { emojis } = useTheme() // the
|
|
187
|
+
* const { emojis } = useTheme() // the starting member's theme, @UseTheme included
|
|
185
188
|
* await click.reply(`${emojis.success} Picked`)
|
|
186
189
|
* }))
|
|
187
190
|
* }
|
|
@@ -9,6 +9,7 @@ import { assertValidTheme, themeForProblem } from '../core/theme-validation.js';
|
|
|
9
9
|
import { copyLayer } from '../core/theme-scope.js';
|
|
10
10
|
import { declaring, deprecatedOnMethod, refuse } from '../util/refusal.util.js';
|
|
11
11
|
import { timeoutProblem } from '../util/timer-limit.util.js';
|
|
12
|
+
import { isConstructor, describeValue } from '../util/value.util.js';
|
|
12
13
|
import { META } from '../util/metadata-keys.js';
|
|
13
14
|
|
|
14
15
|
/** Refuses a `messages` option of the wrong type where the app is declared, rather than at the first message. */ function assertMessageOptions(appName, messages, i18n) {
|
|
@@ -52,6 +53,35 @@ import { META } from '../util/metadata-keys.js';
|
|
|
52
53
|
if (type.labelKey !== undefined) assertLabelKey(appName, name, type.labelKey, i18n);
|
|
53
54
|
}
|
|
54
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Refuses an option that takes a class given anything else, such as an object or an arrow function, where the app is
|
|
58
|
+
* declared: the startup checks read each class's prototype, and would otherwise fail on it without naming the option.
|
|
59
|
+
*/ function assertClassOptions(appName, { presenter, cooldownStore, controllers, services }) {
|
|
60
|
+
for (const [name, value, implementing] of [
|
|
61
|
+
[
|
|
62
|
+
'presenter',
|
|
63
|
+
presenter,
|
|
64
|
+
'ResponsePresenter'
|
|
65
|
+
],
|
|
66
|
+
[
|
|
67
|
+
'cooldownStore',
|
|
68
|
+
cooldownStore,
|
|
69
|
+
'CooldownStore'
|
|
70
|
+
]
|
|
71
|
+
]){
|
|
72
|
+
if (value !== undefined && !isConstructor(value)) {
|
|
73
|
+
throw refuse(new TypeError(`${appName}: @MeoCord({ ${name} }) takes a class implementing ${implementing}, not ${describeValue(value)}.`));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const controller of controllers ?? []){
|
|
77
|
+
if (!isConstructor(controller)) throw refuse(new TypeError(`${appName}: @MeoCord({ controllers }) takes classes, not ${describeValue(controller)}.`));
|
|
78
|
+
}
|
|
79
|
+
// A string or symbol is a token, which a provider has to provide; that is checked as the app is created
|
|
80
|
+
for (const service of services ?? []){
|
|
81
|
+
if (isConstructor(service) || typeof service === 'string' || typeof service === 'symbol') continue;
|
|
82
|
+
throw refuse(new TypeError(`${appName}: @MeoCord({ services }) takes classes, or tokens that providers provide, not ${describeValue(service)}.`));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
55
85
|
/** Refuses a type's `labelKey` that no message of the app's default catalog has, as it would be shown as the key. */ function assertLabelKey(appName, name, labelKey, i18n) {
|
|
56
86
|
const where = `${appName}: @MeoCord({ messages: { types } }): "${name}" has labelKey`;
|
|
57
87
|
if (typeof labelKey !== 'string') throw refuse(new TypeError(`${where} ${String(labelKey)}; it takes a message key, such as 'types.${name}'.`));
|
|
@@ -96,6 +126,7 @@ import { META } from '../util/metadata-keys.js';
|
|
|
96
126
|
*/ function MeoCord(options) {
|
|
97
127
|
return declaring((target, propertyKey)=>{
|
|
98
128
|
if (deprecatedOnMethod('@MeoCord', target, propertyKey)) return;
|
|
129
|
+
assertClassOptions(target.name, options);
|
|
99
130
|
assertStageEntries('@MeoCord({ guards })', 'guard', target.name, options.guards ?? []);
|
|
100
131
|
assertStageEntries('@MeoCord({ interceptors })', 'interceptor', target.name, options.interceptors ?? []);
|
|
101
132
|
assertStageEntries('@MeoCord({ filters })', 'filter', target.name, options.filters ?? []);
|
|
@@ -3,7 +3,7 @@ import { makeInjectable } from '../util/injectable.util.js';
|
|
|
3
3
|
import { assertStageEntries } from '../core/stage-scope.js';
|
|
4
4
|
import { Logger } from '../common/logger.js';
|
|
5
5
|
import { warnDeprecatedBehaviour } from '../common/deprecation.js';
|
|
6
|
-
import {
|
|
6
|
+
import { isConstructible, describeValue } from '../util/value.util.js';
|
|
7
7
|
import { META } from '../util/metadata-keys.js';
|
|
8
8
|
import { declaring, refuseOnMethod } from '../util/refusal.util.js';
|
|
9
9
|
|
|
@@ -40,7 +40,7 @@ import { declaring, refuseOnMethod } from '../util/refusal.util.js';
|
|
|
40
40
|
refuseOnMethod('@Catch', target, propertyKey);
|
|
41
41
|
makeInjectable(target);
|
|
42
42
|
errorTypes.forEach((type, index)=>{
|
|
43
|
-
if (
|
|
43
|
+
if (isConstructible(type)) return;
|
|
44
44
|
const entry = ORDINALS[index] ? `${ORDINALS[index]} entry` : `entry ${index + 1}`;
|
|
45
45
|
warnDeprecatedBehaviour(logger, `${target.name}: @Catch's ${entry}, ${describeValue(type)}, which matches no error,`, 'is refused', 'an error class, such as @Catch(CooldownError),');
|
|
46
46
|
});
|
package/dist/esm/package.json.js
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
import { CATALOGS, placeholderNames } from '../common/translator.js';
|
|
1
|
+
import { isPluralShape, CATALOGS, placeholderNames } from '../common/translator.js';
|
|
2
2
|
import { MEOCORD_MESSAGES } from '../common/meocord-messages.js';
|
|
3
3
|
|
|
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));
|
|
13
4
|
/** Every message key of a catalog, and whether it is a plural. */ function leaves(catalog, prefix = '') {
|
|
14
5
|
return Object.entries(catalog).flatMap(([name, value])=>{
|
|
15
6
|
const key = `${prefix}${name}`;
|
|
@@ -19,7 +10,7 @@ const PLURAL_CATEGORIES = new Set([
|
|
|
19
10
|
plural: false
|
|
20
11
|
}
|
|
21
12
|
];
|
|
22
|
-
if (
|
|
13
|
+
if (isPluralShape(value)) return [
|
|
23
14
|
{
|
|
24
15
|
key,
|
|
25
16
|
plural: true
|
|
@@ -43,7 +34,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
43
34
|
/** A message as a report shows it: the text, or a plural's `other` form. */ const shown = (message)=>typeof message === 'string' ? message : String(message.other);
|
|
44
35
|
/** The `{params}` a translation uses that the message it translates doesn't take; a plural's `{count}` is taken. */ function strayParams(translation, original) {
|
|
45
36
|
const taken = placeholders(original);
|
|
46
|
-
if (
|
|
37
|
+
if (isPluralShape(original)) taken.add('count');
|
|
47
38
|
return [
|
|
48
39
|
...placeholders(translation)
|
|
49
40
|
].filter((name)=>!taken.has(name));
|
|
@@ -109,7 +100,7 @@ const isMeoCordKey = (key)=>key.startsWith('meocord.');
|
|
|
109
100
|
continue;
|
|
110
101
|
}
|
|
111
102
|
// 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 !==
|
|
103
|
+
if (plural !== isPluralShape(message) || !plural && typeof message !== 'string') {
|
|
113
104
|
problems.push(`${key} should be a ${plural ? 'plural' : 'text'}, as the default is`);
|
|
114
105
|
continue;
|
|
115
106
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GuildChannel, MessageReaction, MessageMentions, Message, GuildMember, BaseGuild, Guild, ModalSubmitInteraction, TextChannel, ThreadChannel, BaseGuildVoiceChannel, GuildPremiumTier, GuildVerificationLevel, GuildNSFWLevel, Locale, PresenceManager, ClientUser, UserFlagsBitField, User, GuildMemberFlagsBitField, Role, Collection, MessageType, ReactionManager, ChatInputCommandInteraction, CommandInteraction, AutocompleteInteraction, ReactionUserManager, BaseInteraction, ChannelType, NewsChannel, VoiceChannel, ForumChannel, MediaChannel, DMChannel, BaseChannel, Client, PermissionsBitField, PermissionFlagsBits } from 'discord.js';
|
|
1
|
+
import { GuildChannel, MessageReaction, MessageMentions, Message, GuildMember, BaseGuild, Guild, ModalSubmitInteraction, TextChannel, ThreadChannel, BaseGuildVoiceChannel, GuildPremiumTier, GuildVerificationLevel, GuildNSFWLevel, Locale, PresenceManager, ClientUser, UserFlagsBitField, User, GuildMemberFlagsBitField, GuildEmoji, Sticker, GuildScheduledEvent, AutoModerationRule, Invite, Role, Collection, MessageType, ReactionManager, ChatInputCommandInteraction, CommandInteraction, AutocompleteInteraction, ReactionUserManager, BaseInteraction, ChannelType, NewsChannel, VoiceChannel, ForumChannel, MediaChannel, DMChannel, BaseChannel, Client, PermissionsBitField, PermissionFlagsBits } from 'discord.js';
|
|
2
2
|
import { createMockChannel, createMockGuild, createMockMessage, ownedManager, memberRoles, createMockUser, createChatInputOptions, readPlaceAs, botMemberOf, heldChannelOf, isRawMember, isUnsetEveryone, NEW_SERVER_EVERYONE_PERMISSIONS } from './mock-interaction.js';
|
|
3
3
|
import { nextSnowflake } from './snowflake.js';
|
|
4
4
|
import { strictMocks } from './strict-mocks.js';
|
|
@@ -129,6 +129,26 @@ const channelPermissions = GuildChannel.prototype;
|
|
|
129
129
|
roles: (member)=>memberRoles(member, []),
|
|
130
130
|
permissions: REAL_GETTER
|
|
131
131
|
}),
|
|
132
|
+
// A server's emoji, sticker, scheduled event and AutoMod rule are named, and what discord.js formats from them
|
|
133
|
+
// computes: an emoji's mention and identifier, an event's and an invite's link
|
|
134
|
+
defaultsOf(GuildEmoji, {
|
|
135
|
+
name: ()=>'emoji',
|
|
136
|
+
animated: ()=>false,
|
|
137
|
+
identifier: REAL_GETTER
|
|
138
|
+
}),
|
|
139
|
+
defaultsOf(Sticker, {
|
|
140
|
+
name: ()=>'sticker'
|
|
141
|
+
}),
|
|
142
|
+
defaultsOf(GuildScheduledEvent, {
|
|
143
|
+
name: ()=>'event',
|
|
144
|
+
url: REAL_GETTER
|
|
145
|
+
}),
|
|
146
|
+
defaultsOf(AutoModerationRule, {
|
|
147
|
+
name: ()=>'rule'
|
|
148
|
+
}),
|
|
149
|
+
defaultsOf(Invite, {
|
|
150
|
+
url: REAL_GETTER
|
|
151
|
+
}),
|
|
132
152
|
defaultsOf(Role, {
|
|
133
153
|
name: ()=>'role',
|
|
134
154
|
// Above @everyone, which a server's role manager makes at position 0
|
|
@@ -34,9 +34,10 @@ import { Dispatcher } from '../core/dispatcher.js';
|
|
|
34
34
|
import { createFallback } from '../core/fallback.js';
|
|
35
35
|
import { Logger } from '../common/logger.js';
|
|
36
36
|
import { isExplainedError, markExplained } from '../common/explained-error.js';
|
|
37
|
-
import {
|
|
37
|
+
import { collectStartupErrors, declaredErrorsOf, startupError, stopOnStartupErrors, isRefusal, logStartupErrors } from '../util/refusal.util.js';
|
|
38
38
|
import { DEFAULT_SHUTDOWN_TIMEOUT_MS, shutdownTimeoutProblem } from '../util/shutdown-timeout.util.js';
|
|
39
|
-
import {
|
|
39
|
+
import { isConstructor, describeValue } from '../util/value.util.js';
|
|
40
|
+
import { resolveProviders, tokenName, bindsOwnToken, providerMap, assertProvidedServices, bindProvider, resolutionOrder, isClassProvider, assertProvided, tokenDependencies } from '../core/providers.js';
|
|
40
41
|
import { getEventHandlers } from '../decorator/event.decorator.js';
|
|
41
42
|
import { getReactionHandlers } from '../decorator/controller.decorator.js';
|
|
42
43
|
import { strictMocks } from './strict-mocks.js';
|
|
@@ -803,7 +804,17 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
803
804
|
}
|
|
804
805
|
// Each pass of checks ends before the next step that is no check, so a module with one error stops where it always did
|
|
805
806
|
stopOnStartupErrors();
|
|
807
|
+
// What the walk below takes as classes, reported with what the walk finds; an entry refused here is left out of it
|
|
806
808
|
const services = this.wiring?.services ?? [];
|
|
809
|
+
if (this.options.app) assertProvidedServices(this.options.app.name, services, providers, "@MeoCord({ providers }) or the testing module's providers");
|
|
810
|
+
for (const controller of this.options.controllers ?? []){
|
|
811
|
+
if (!isConstructor(controller)) startupError(new Error(`the testing module's controllers takes classes, not ${describeValue(controller)}.`));
|
|
812
|
+
}
|
|
813
|
+
const observers = [
|
|
814
|
+
...this.options.app ? appObservers(this.options.app) : [],
|
|
815
|
+
...this.options.observers ?? []
|
|
816
|
+
];
|
|
817
|
+
assertObservers("the testing module's observers", observers);
|
|
807
818
|
const appOptions = this.options.app ? Reflect.getMetadata(META.appOptions, this.options.app) : undefined;
|
|
808
819
|
// The app's own store, as the bot binds it, whether the module is given the app or built from it; none when the
|
|
809
820
|
// test provides the CooldownStore, so nothing builds the app's or asks for what it injects
|
|
@@ -814,17 +825,14 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
814
825
|
const i18n = this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.i18n;
|
|
815
826
|
const appPresenterClass = this.options.app && Reflect.getMetadata(META.appOptions, this.options.app)?.presenter;
|
|
816
827
|
const startup = startupClasses({
|
|
817
|
-
controllers: this.options.controllers ?? [],
|
|
828
|
+
controllers: (this.options.controllers ?? []).filter(isConstructor),
|
|
818
829
|
services,
|
|
819
830
|
providers,
|
|
820
831
|
stages,
|
|
821
832
|
presenter: appPresenterClass,
|
|
822
833
|
cooldownStore: store,
|
|
823
834
|
themeFor: stages?.themeFor?.resolvers,
|
|
824
|
-
observers
|
|
825
|
-
...this.options.app ? appObservers(this.options.app) : [],
|
|
826
|
-
...this.options.observers ?? []
|
|
827
|
-
],
|
|
835
|
+
observers,
|
|
828
836
|
stubs: [
|
|
829
837
|
...this.guardOverrides.keys(),
|
|
830
838
|
...this.interceptorOverrides.keys(),
|
|
@@ -914,13 +922,6 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
914
922
|
// As the app does when it is created, so a test sees the refusal the bot would give
|
|
915
923
|
assertDistinctCommands(this.options.controllers ?? []);
|
|
916
924
|
if (this.options.app) prepareAppPresenter(container, this.options.app);
|
|
917
|
-
const observers = [
|
|
918
|
-
...this.options.app ? appObservers(this.options.app) : [],
|
|
919
|
-
...this.options.observers ?? []
|
|
920
|
-
];
|
|
921
|
-
assertObservers("the testing module's observers", observers);
|
|
922
|
-
// Only @Observer classes are bound as observers
|
|
923
|
-
stopOnStartupErrors();
|
|
924
925
|
bindObservers(container, observers);
|
|
925
926
|
// The store calls ask: the test's own when it provides CooldownStore, else the app's
|
|
926
927
|
const boundStore = store ?? (providers.has(CooldownStore) ? CooldownStore : undefined);
|
|
@@ -1060,7 +1061,8 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
1060
1061
|
* @param app - The class `@MeoCord` decorates.
|
|
1061
1062
|
* @param options - Providers that replace the app's by token, extra controllers, and observers.
|
|
1062
1063
|
* @returns The builder, whose `override*` methods still apply, for `compile()`.
|
|
1063
|
-
* @throws TypeError when `app` is not a `@MeoCord` class.
|
|
1064
|
+
* @throws TypeError when `app` is not a `@MeoCord` class. After {@link reportAllStartupErrors}, an app whose
|
|
1065
|
+
* `@MeoCord` was refused throws the error it was refused for instead.
|
|
1064
1066
|
*
|
|
1065
1067
|
* @example
|
|
1066
1068
|
* ```ts
|
|
@@ -1101,7 +1103,13 @@ import { isUserOutcome } from '../common/user-outcome.js';
|
|
|
1101
1103
|
* ```
|
|
1102
1104
|
*/ static fromApp(app, options = {}) {
|
|
1103
1105
|
const appOptions = Reflect.getMetadata(META.appOptions, app);
|
|
1104
|
-
if (!appOptions)
|
|
1106
|
+
if (!appOptions) {
|
|
1107
|
+
// A refused @MeoCord stores no options, so its own errors, kept with startupErrors: 'all', say why
|
|
1108
|
+
collectStartupErrors(()=>declaredErrorsOf([
|
|
1109
|
+
app
|
|
1110
|
+
]).forEach(startupError));
|
|
1111
|
+
throw new TypeError(`${app?.name ?? String(app)} is not a @MeoCord app: fromApp takes the class @MeoCord decorates.`);
|
|
1112
|
+
}
|
|
1105
1113
|
const controllers = [
|
|
1106
1114
|
...new Set([
|
|
1107
1115
|
...appOptions.controllers,
|