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.
Files changed (140) hide show
  1. package/CHANGELOG.md +362 -0
  2. package/THIRD_PARTY_NOTICES.md +14 -13
  3. package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-XKMlcUZz.cjs} +669 -185
  4. package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-BZgJyfZS.cjs} +218 -75
  5. package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
  6. package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CgfPQrNl.cjs} +374 -132
  7. package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-BUAR1WYG.cjs} +21 -7
  8. package/dist/cjs/common/index.cjs +4 -4
  9. package/dist/cjs/core/index.cjs +365 -310
  10. package/dist/cjs/decorator/index.cjs +85 -61
  11. package/dist/cjs/testing/index.cjs +2236 -496
  12. package/dist/cli.json +1 -1
  13. package/dist/esm/bin/app-template/README.md.template +3 -1
  14. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +8 -4
  15. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +2 -2
  16. package/dist/esm/bin/app-template/src/guards/owner.guard.spec.ts.template +8 -4
  17. package/dist/esm/bin/app-template/src/guards/owner.guard.ts.template +1 -1
  18. package/dist/esm/bin/app-template/vitest.config.ts.template +1 -1
  19. package/dist/esm/bin/app-template/vitest.setup.ts.template +7 -3
  20. package/dist/esm/bin/generator.js +4 -5
  21. package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
  22. package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
  23. package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
  24. package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
  25. package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
  26. package/dist/esm/bin/helper/service-generator.helper.js +4 -5
  27. package/dist/esm/bin/meocord.js +84 -25
  28. package/dist/esm/build/load-config.pre-entry.js +17 -4
  29. package/dist/esm/build/rsbuild-config.js +16 -12
  30. package/dist/esm/build/typed-externals.js +36 -0
  31. package/dist/esm/common/cooldown-store.js +10 -2
  32. package/dist/esm/common/decorator.js +6 -6
  33. package/dist/esm/common/deprecation.js +2 -2
  34. package/dist/esm/common/errors.js +27 -2
  35. package/dist/esm/common/execution-context.js +1 -1
  36. package/dist/esm/common/meocord-text.js +11 -8
  37. package/dist/esm/common/metadata.js +1 -1
  38. package/dist/esm/common/response/call-order.js +9 -4
  39. package/dist/esm/common/response/presenter.js +33 -6
  40. package/dist/esm/common/response/response-state.js +84 -10
  41. package/dist/esm/common/route.js +21 -5
  42. package/dist/esm/common/sharded-cooldown-store.js +1 -1
  43. package/dist/esm/common/translate-error.js +1 -1
  44. package/dist/esm/common/translator.js +37 -6
  45. package/dist/esm/core/command-conflicts.js +34 -34
  46. package/dist/esm/core/command-registration.js +93 -24
  47. package/dist/esm/core/command-routes.js +2 -2
  48. package/dist/esm/core/component-routes.js +74 -31
  49. package/dist/esm/core/cooldown-runner.js +29 -8
  50. package/dist/esm/core/dispatcher.js +51 -7
  51. package/dist/esm/core/fallback.js +20 -11
  52. package/dist/esm/core/filter-runner.js +2 -2
  53. package/dist/esm/core/guard-runner.js +16 -3
  54. package/dist/esm/core/handler-input.js +4 -3
  55. package/dist/esm/core/handler-pipeline.js +27 -14
  56. package/dist/esm/core/handler-registry.js +1 -1
  57. package/dist/esm/core/interceptor-runner.js +2 -2
  58. package/dist/esm/core/lifecycle-hooks.js +134 -47
  59. package/dist/esm/core/meocord-factory.js +241 -179
  60. package/dist/esm/core/meocord.app.js +116 -75
  61. package/dist/esm/core/message-routes.js +4 -3
  62. package/dist/esm/core/observer-runner.js +4 -3
  63. package/dist/esm/core/providers.js +45 -23
  64. package/dist/esm/core/route-specificity.js +4 -4
  65. package/dist/esm/core/running-calls.js +43 -0
  66. package/dist/esm/core/scalar-types.js +15 -3
  67. package/dist/esm/core/shard-context.js +6 -7
  68. package/dist/esm/core/shard-manager.js +16 -10
  69. package/dist/esm/core/startup-roots.js +23 -4
  70. package/dist/esm/core/theme-resolvers.js +4 -1
  71. package/dist/esm/core/theme-runtime.js +2 -2
  72. package/dist/esm/core/theme-scope.js +3 -1
  73. package/dist/esm/core/theme-validation.js +17 -10
  74. package/dist/esm/decorator/app.decorator.js +12 -7
  75. package/dist/esm/decorator/command-builder.decorator.js +4 -4
  76. package/dist/esm/decorator/controller-class.decorator.js +9 -4
  77. package/dist/esm/decorator/controller.decorator.js +162 -83
  78. package/dist/esm/decorator/cooldown.decorator.js +20 -3
  79. package/dist/esm/decorator/defer.decorator.js +4 -4
  80. package/dist/esm/decorator/event.decorator.js +5 -5
  81. package/dist/esm/decorator/filter.decorator.js +7 -7
  82. package/dist/esm/decorator/guard.decorator.js +11 -10
  83. package/dist/esm/decorator/inject.decorator.js +4 -4
  84. package/dist/esm/decorator/interceptor.decorator.js +7 -7
  85. package/dist/esm/decorator/observer.decorator.js +4 -4
  86. package/dist/esm/decorator/service.decorator.js +4 -4
  87. package/dist/esm/decorator/theme.decorator.js +3 -3
  88. package/dist/esm/decorator/validation.decorator.js +10 -10
  89. package/dist/esm/package.json.js +1 -1
  90. package/dist/esm/testing/catalog.js +27 -11
  91. package/dist/esm/testing/cooldown-store-suite.js +85 -15
  92. package/dist/esm/testing/discord-defaults.js +249 -21
  93. package/dist/esm/testing/index.js +4 -2
  94. package/dist/esm/testing/inspect-handler.js +20 -3
  95. package/dist/esm/testing/meocord-testing-module.js +352 -205
  96. package/dist/esm/testing/mock-fn.js +173 -22
  97. package/dist/esm/testing/mock-interaction.js +1214 -209
  98. package/dist/esm/testing/response.js +1 -1
  99. package/dist/esm/testing/routing.js +39 -14
  100. package/dist/esm/testing/snowflake.js +9 -2
  101. package/dist/esm/testing/startup-errors.js +26 -0
  102. package/dist/esm/testing/strict-mocks.js +65 -0
  103. package/dist/esm/testing/theme.js +1 -1
  104. package/dist/esm/util/bundle-entry.util.js +2 -1
  105. package/dist/esm/util/common.util.js +47 -19
  106. package/dist/esm/util/dev-runner.util.js +8 -8
  107. package/dist/esm/util/generator-cli.util.js +4 -5
  108. package/dist/esm/util/interaction.util.js +3 -3
  109. package/dist/esm/util/meocord-config-loader.util.js +10 -2
  110. package/dist/esm/util/meocord-config-validation.util.js +4 -5
  111. package/dist/esm/util/meocord-source-config.util.js +65 -20
  112. package/dist/esm/util/metadata-keys.js +1 -0
  113. package/dist/esm/util/method-wrapper.util.js +16 -0
  114. package/dist/esm/util/platform.util.js +10 -10
  115. package/dist/esm/util/refusal.util.js +148 -8
  116. package/dist/esm/util/shard-process.util.js +4 -1
  117. package/dist/esm/util/sharding-mode.util.js +5 -7
  118. package/dist/esm/util/shared-state.util.js +21 -0
  119. package/dist/esm/util/tsconfig-paths.util.js +29 -0
  120. package/dist/esm/util/tsconfig.util.js +10 -10
  121. package/dist/esm/util/value.util.js +12 -1
  122. package/dist/types/common/index.d.cts +8 -8
  123. package/dist/types/common/index.d.ts +8 -8
  124. package/dist/types/core/index.d.cts +10 -7
  125. package/dist/types/core/index.d.ts +10 -7
  126. package/dist/types/decorator/index.d.cts +31 -29
  127. package/dist/types/decorator/index.d.ts +31 -29
  128. package/dist/types/{index-D7n0FBMm.d.ts → index-DKJclIi-.d.ts} +107 -36
  129. package/dist/types/{index-DEP0NYrU.d.cts → index-Dlf0ZZtS.d.cts} +107 -36
  130. package/dist/types/interface/index.d.cts +1 -1
  131. package/dist/types/interface/index.d.ts +1 -1
  132. package/dist/types/{route-C-aMI7Sl.d.cts → route-Bh1U13yy.d.cts} +8 -4
  133. package/dist/types/{route-C-aMI7Sl.d.ts → route-Bh1U13yy.d.ts} +8 -4
  134. package/dist/types/testing/index.d.cts +406 -70
  135. package/dist/types/testing/index.d.ts +406 -70
  136. package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
  137. package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
  138. package/dist/types/{translator-J6tIUfdp.d.ts → translator-Bg7hwxDF.d.cts} +21 -8
  139. package/dist/types/{translator-B4mc1lzJ.d.cts → translator-CeP4bCzp.d.ts} +21 -8
  140. 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:${encodeURIComponent(String(value))}`;
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}:${scopeId(per, first)}${suffix}`,
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
- * The store key and window of the cooldown each refusal came from, and when its wait ends by the store's clock if
237
- * the store said, for {@link claimCooldownNotice}.
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: `${refused.key}:notice:at:${refused.endsAt}`,
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: `${refused.key}:notice:${at}`,
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
- await this.invokeHandler(this.getInstance(route.controllerClass), route.method, [
301
- message,
302
- params
303
- ], call, hooks);
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
- await this.invokeHandler(this.getInstance(controllerClass), method, [
307
- message
308
- ], call);
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`. */ const replyText = (text, withEmoji)=>withEmoji ? `${useTheme().emojis.warning} ${text}` : text;
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(text, withEmoji)
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 { refuse } from '../util/refusal.util.js';
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
- throw refuse(new Error(`${cls.name || 'A filter'}: used as an exception filter, but not decorated with @Catch().`));
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, or a class it extends: a subclass the app's code makes runs as its base does
348
- const container = Reflect.getMetadata(META.container, controller);
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[customId] = entriesOf(interaction.fields.getUploadedFiles(customId));
22
- else values[customId] = 'value' in data ? data.value : data.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 in fields);
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, handlerGuards, runGuards, callGuardedHandler, isGuardWithParams } from './guard-runner.js';
7
- import { prepareInterceptor, handlerInterceptors, bindShared, interceptorClass, runInterceptors } from './interceptor-runner.js';
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, pipeClass, prepareHandlerArgs } from './input-runner.js';
10
+ import { handlerInputStages, preparePipe, prepareHandlerArgs, pipeClass } from './input-runner.js';
11
11
  import { hasObservers, notifyStart, outcomeOf, responsePhaseOf, notifyObservers } from './observer-runner.js';
12
- import { methodCooldowns, handlerCooldowns, peekCooldowns } from './cooldown-runner.js';
12
+ import { handlerCooldowns, peekCooldowns, methodCooldowns } from './cooldown-runner.js';
13
13
  import { Logger } from '../common/logger.js';
14
- import { configureThemes, beginCallTheme } from './theme-runtime.js';
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
- bindShared(container, options.presenter);
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) throw refuse(deferMisuseError(controller.name, method, 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
- throw refuse(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.`));
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
- throw refuse(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}.`));
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
- throw refuse(new Error(`${controller.name}.${method}: @Cooldown is for interaction and message handlers, and this is ${handler}.`));
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.1/handler-discovery | Handler discovery}
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 { refuse } from '../util/refusal.util.js';
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)) throw refuse(singletonContextError(cls));
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();