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.
Files changed (36) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/cjs/_shared/{controller.decorator-XKMlcUZz.cjs → controller.decorator-U2NBWcoU.cjs} +45 -13
  3. package/dist/cjs/_shared/{providers-BZgJyfZS.cjs → providers-B4MOFBrW.cjs} +58 -15
  4. package/dist/cjs/_shared/{sharded-cooldown-store-CsJJyCZd.cjs → sharded-cooldown-store-HTUb3Ii_.cjs} +1 -1
  5. package/dist/cjs/_shared/{startup-roots-CgfPQrNl.cjs → startup-roots-lA5xkBFq.cjs} +12 -11
  6. package/dist/cjs/_shared/{translate-error-BUAR1WYG.cjs → translate-error-CbBHUu5P.cjs} +1 -1
  7. package/dist/cjs/common/index.cjs +3 -3
  8. package/dist/cjs/core/index.cjs +8 -6
  9. package/dist/cjs/decorator/index.cjs +33 -3
  10. package/dist/cjs/testing/index.cjs +295 -60
  11. package/dist/cli.json +1 -1
  12. package/dist/esm/common/response/response-state.js +5 -5
  13. package/dist/esm/common/translator.js +15 -5
  14. package/dist/esm/core/dispatcher.js +9 -8
  15. package/dist/esm/core/filter-runner.js +2 -2
  16. package/dist/esm/core/handler-pipeline.js +10 -4
  17. package/dist/esm/core/meocord-factory.js +4 -2
  18. package/dist/esm/core/message-help.js +32 -6
  19. package/dist/esm/core/observer-runner.js +2 -1
  20. package/dist/esm/core/providers.js +12 -2
  21. package/dist/esm/core/stage-scope.js +2 -1
  22. package/dist/esm/core/theme-scope.js +4 -1
  23. package/dist/esm/decorator/app.decorator.js +31 -0
  24. package/dist/esm/decorator/filter.decorator.js +2 -2
  25. package/dist/esm/package.json.js +1 -1
  26. package/dist/esm/testing/catalog.js +4 -13
  27. package/dist/esm/testing/discord-defaults.js +21 -1
  28. package/dist/esm/testing/meocord-testing-module.js +24 -16
  29. package/dist/esm/testing/mock-interaction.js +242 -28
  30. package/dist/esm/testing/strict-mocks.js +7 -4
  31. package/dist/esm/util/value.util.js +19 -2
  32. package/dist/types/common/index.d.cts +4 -1
  33. package/dist/types/common/index.d.ts +4 -1
  34. package/dist/types/testing/index.d.cts +28 -14
  35. package/dist/types/testing/index.d.ts +28 -14
  36. 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
- /** A message by its dotted key, or undefined when the catalog lacks it or the key names a group. */ function lookup(catalog, key) {
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 (typeof current === 'object' && current !== null && typeof current.other === 'string') {
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 ? (phase, returnedBy)=>this.warnUnansweredOnce(handler, phase, returnedBy && {
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, or that
258
- * `interceptor` returned before the handler ran or finished, and the call ended so.
259
- */ warnUnansweredOnce(handler, phase, interceptor) {
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 { isConstructor } from '../util/value.util.js';
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)=>isConstructor(type) && error instanceof type)) return entry;
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
- await response?.abandon();
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 blamed = !finished && returnedEarly ? {
407
+ const returnedBy = !finished && returnedEarly ? {
406
408
  interceptor: returnedEarly,
407
409
  handlerStarted: ran
408
410
  } : undefined;
409
- if (phase === 'unanswered' || phase === 'deferred') options.onUnanswered(phase, blamed);
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
- return providerMap(options.providers ?? [], '@MeoCord({ providers })');
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
- /** Splits a reply into Discord-sized messages at line breaks, a line longer than one message cut where it must be. */ function splitReply(text) {
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;; piece = piece.slice(MAX_REPLY_LENGTH)){
257
- const part = piece.slice(0, MAX_REPLY_LENGTH);
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
- chunks.push(current);
286
+ push(current);
262
287
  current = part;
263
288
  }
264
- if (piece.length <= MAX_REPLY_LENGTH) break;
289
+ if (cut === piece.length) break;
290
+ piece = piece.slice(cut);
265
291
  }
266
292
  }
267
- if (current) chunks.push(current);
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' ? observer.name : String(observer);
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 && typeof entry.useClass !== 'function') {
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
- return isConstructor(entry) ? entry : entry.provide;
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 handler's theme, @UseTheme included
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 { isConstructor, describeValue } from '../util/value.util.js';
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 (isConstructor(type)) return;
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
  });
@@ -1,4 +1,4 @@
1
- var version = "4.2.1";
1
+ var version = "4.2.2";
2
2
  var engines = {
3
3
  node: ">=22.13"
4
4
  };
@@ -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 (isPlural(value)) return [
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 (isPlural(original)) taken.add('count');
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 !== isPlural(message) || !plural && typeof message !== 'string') {
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 { declaredErrorsOf, collectStartupErrors, startupError, stopOnStartupErrors, isRefusal, logStartupErrors } from '../util/refusal.util.js';
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 { resolveProviders, tokenName, bindsOwnToken, providerMap, bindProvider, resolutionOrder, isClassProvider, assertProvided, tokenDependencies } from '../core/providers.js';
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) throw new TypeError(`${app?.name ?? String(app)} is not a @MeoCord app: fromApp takes the class @MeoCord decorates.`);
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,