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
@@ -1,13 +1,40 @@
1
1
  'use strict';
2
2
 
3
3
  var discord_js = require('discord.js');
4
- var controller_decorator = require('./controller.decorator-BywwkU9O.cjs');
5
- var providers = require('./providers-BMMLp2SN.cjs');
4
+ var controller_decorator = require('./controller.decorator-XKMlcUZz.cjs');
5
+ var providers = require('./providers-BZgJyfZS.cjs');
6
6
  var enum_index = require('../enum/index.cjs');
7
- var translateError = require('./translate-error-BnM8FbSH.cjs');
7
+ var node_async_hooks = require('node:async_hooks');
8
+ var translateError = require('./translate-error-BUAR1WYG.cjs');
8
9
 
9
10
  /**
10
- * Every customId-pattern route of the given controllers, most specific first, read from metadata alone.
11
+ * How a pattern ranks, one character per segment twice over: `L` for literal text or `P` for a param, then how narrow
12
+ * each param is, the narrowest as the lowest digit. Two keys of one length compare as text: literal before param at
13
+ * the first segment where they differ, then the narrower type.
14
+ */ function rankKey(pattern) {
15
+ const narrowness = pattern.split('/').map(controller_decorator.paramNarrowness);
16
+ const kinds = narrowness.map((rank)=>rank === undefined ? 'L' : 'P').join('');
17
+ return `${kinds}/${narrowness.map((rank)=>String(rank === undefined ? 0 : 9 - rank)).join('')}`;
18
+ }
19
+ /**
20
+ * Orders two routes as dispatch tries them. Left to right, the first segment one pattern spells out as literal text
21
+ * while the other leaves it to a param ranks the literal one first: `a/{x}` before `{x}/abcd`, `profile/me/{section}`
22
+ * before `profile/{userId}/edit`. Between patterns that leaves tied, the first param where one type is narrower ranks
23
+ * it first: words to choose from, then `bool`, `uuid`, `snowflake`, `int`, `number`, then text. Patterns of different
24
+ * segment counts, which no customId matches both of, are ordered by count only so the order is total; component
25
+ * patterns have no catch-all param. What remains tied keeps its listing order.
26
+ */ function rank(keys) {
27
+ return (a, b)=>{
28
+ const [left, right] = [
29
+ keys.get(a),
30
+ keys.get(b)
31
+ ];
32
+ if (left.length !== right.length) return left.length - right.length;
33
+ return left < right ? -1 : left > right ? 1 : 0;
34
+ };
35
+ }
36
+ /**
37
+ * Every customId-pattern route of the given controllers, in the order dispatch tries them, read from metadata alone.
11
38
  * Throws for two handlers whose patterns match the same customIds of one component type; one handler
12
39
  * declared under two spellings of a pattern keeps one route.
13
40
  */ function buildComponentRoutes(controllerClasses) {
@@ -40,22 +67,11 @@ var translateError = require('./translate-error-BnM8FbSH.cjs');
40
67
  }
41
68
  }
42
69
  }
43
- // A stable sort, so equally specific patterns keep the order their controllers and handlers are listed in
44
- return routes.sort((a, b)=>(b.meta.specificity ?? 0) - (a.meta.specificity ?? 0));
45
- }
46
- /**
47
- * Whether `a` spells out the first segment where it and `b` differ as literal text while `b` leaves it to a
48
- * parameter, the tie-break between equally specific patterns in the next major version (5.0).
49
- */ function literalFirst(a, b) {
50
- const [left, right] = [
51
- a.split('/'),
52
- b.split('/')
53
- ];
54
- for(let i = 0; i < Math.min(left.length, right.length); i++){
55
- const leftLiteral = !left[i].includes('{');
56
- if (leftLiteral !== !right[i].includes('{')) return leftLiteral;
57
- }
58
- return false;
70
+ // A stable sort, so what the ranking leaves tied keeps the order controllers and handlers are listed in
71
+ return routes.sort(rank(new Map(routes.map((route)=>[
72
+ route,
73
+ rankKey(route.pattern)
74
+ ]))));
59
75
  }
60
76
  /** A component type as an error names it: `button`, `modal submit`, `select menu`. */ const typeLabel = (type)=>type.toLowerCase().replaceAll('_', ' ');
61
77
  /**
@@ -80,6 +96,12 @@ const typesByPattern = new Map();
80
96
  if (!types) typesByPattern.set(pattern, types = controller_decorator.createRegexFromPattern(pattern).types);
81
97
  return types;
82
98
  }
99
+ /**
100
+ * The params `pattern`, compiled as `regex`, captures from a customId, each typed param as its value:
101
+ * `undefined` when the customId does not match, or a segment is not a value of its param's type.
102
+ */ function readCustomId(pattern, regex, customId) {
103
+ return readCustomIdSegments(pattern, regex, customId)?.values;
104
+ }
83
105
  /** {@link readCustomId}'s values, with each param's text as the customId gave it. */ function readCustomIdSegments(pattern, regex, customId) {
84
106
  const match = regex.exec(customId);
85
107
  if (!match) return undefined;
@@ -97,18 +119,40 @@ const typesByPattern = new Map();
97
119
  text
98
120
  };
99
121
  }
100
- /** Pattern pairs that can match one customId, compared only within a component type, as dispatch does. */ function findComponentRouteConflicts(routes) {
101
- const byType = new Map();
102
- for (const { meta, pattern } of routes)byType.set(meta.type, [
103
- ...byType.get(meta.type) ?? [],
104
- pattern
105
- ]);
106
- return [
107
- ...byType
108
- ].flatMap(([type, patterns])=>controller_decorator.findAmbiguousRoutes(patterns).map((pair)=>({
109
- type,
110
- patterns: pair
111
- })));
122
+ /**
123
+ * Pattern pairs of one component type that the ranking leaves tied and that can match one customId, each with the
124
+ * pattern that runs, the one ranked first in `routes`, and an id both match. Only patterns of one rank key, read
125
+ * together, can tie, so each group is compared within itself.
126
+ */ function findComponentRouteConflicts(routes) {
127
+ const groups = new Map();
128
+ for (const route of routes){
129
+ // The literal text and each param's narrowness: patterns that differ in either rank apart or match no id in common
130
+ const key = `${route.meta.type}\0${route.pattern.split('/').map((segment)=>controller_decorator.paramNarrowness(segment) ?? `=${segment}`).join('\0')}`;
131
+ groups.set(key, [
132
+ ...groups.get(key) ?? [],
133
+ route
134
+ ]);
135
+ }
136
+ const conflicts = [];
137
+ for (const group of groups.values()){
138
+ for(let i = 0; i < group.length; i++){
139
+ for(let j = i + 1; j < group.length; j++){
140
+ const customId = controller_decorator.sharedCustomId(group[i].pattern, group[j].pattern);
141
+ if (customId === undefined) continue;
142
+ const patterns = [
143
+ group[i].pattern,
144
+ group[j].pattern
145
+ ];
146
+ conflicts.push({
147
+ type: group[i].meta.type,
148
+ patterns,
149
+ runs: group[i].pattern,
150
+ customId
151
+ });
152
+ }
153
+ }
154
+ }
155
+ return conflicts;
112
156
  }
113
157
 
114
158
  const where = ({ controllerClass, meta })=>`${controllerClass.name}.${meta.methodName}`;
@@ -131,7 +175,7 @@ const where = ({ controllerClass, meta })=>`${controllerClass.name}.${meta.metho
131
175
  * Refuses commands of which only one could run or be registered: two handlers of one slash command name or subcommand
132
176
  * path or of one context menu name and kind, or two builders of one command; a command's subcommands, and a user and a
133
177
  * message context menu of one name, are no clash. The app, `meocord register`, the shard manager and testing call it.
134
- * @throws Error naming both handlers, or both builders, with what to do instead.
178
+ * Each clash is a startup error, naming both handlers or both builders with what to do instead; see `startupError`.
135
179
  */ function assertDistinctCommands(controllerClasses) {
136
180
  const handlers = new Map();
137
181
  const builders = new Map();
@@ -149,7 +193,8 @@ const where = ({ controllerClass, meta })=>`${controllerClass.name}.${meta.metho
149
193
  const key = `${meta.type}\0${name}`;
150
194
  const earlier = (handlers.get(key) ?? []).find((other)=>overlap(other.meta, meta));
151
195
  if (earlier) {
152
- throw controller_decorator.refuse(new Error(`${where(earlier)}: it and ${where(here)} both handle the ${describe(meta.type, name, contextMenuKind(meta) ?? contextMenuKind(earlier.meta))}, ` + `so only ${where(earlier)} would ever run. Keep one handler for it, or give the other a name or ` + `subcommand path of its own.`));
196
+ controller_decorator.startupError(new Error(`${where(earlier)}: it and ${where(here)} both handle the ${describe(meta.type, name, contextMenuKind(meta) ?? contextMenuKind(earlier.meta))}, ` + `so only ${where(earlier)} would ever run. Keep one handler for it, or give the other a name or ` + `subcommand path of its own.`));
197
+ continue;
153
198
  }
154
199
  handlers.set(key, [
155
200
  ...handlers.get(key) ?? [],
@@ -170,7 +215,7 @@ const where = ({ controllerClass, meta })=>`${controllerClass.name}.${meta.metho
170
215
  name: typeof body.name === 'string' ? body.name : name
171
216
  });
172
217
  } else if (built.meta.builderClass !== meta.builderClass) {
173
- throw controller_decorator.refuse(new Error(`${where(built)}: its builder ${built.meta.builderClass.name} and ${meta.builderClass.name} on ${where(here)} both ` + `build the ${describe(meta.type, built.name, contextMenuKind(meta))}, and Discord registers one command ` + `per name and type, so only the first would be. Keep one builder, on a single @Command, and declare ` + `the other handlers with CommandType.${meta.type}.`));
218
+ controller_decorator.startupError(new Error(`${where(built)}: its builder ${built.meta.builderClass.name} and ${meta.builderClass.name} on ${where(here)} both ` + `build the ${describe(meta.type, built.name, contextMenuKind(meta))}, and Discord registers one command ` + `per name and type, so only the first would be. Keep one builder, on a single @Command, and declare ` + `the other handlers with CommandType.${meta.type}.`));
174
219
  }
175
220
  }
176
221
  }
@@ -348,18 +393,21 @@ const quoted = (names)=>names.length < 2 ? names.map((name)=>`"${name}"`).join('
348
393
  /**
349
394
  * Warns about a handler a subclass re-declares on other routes while it still answers the ones it inherits, for
350
395
  * `@Command`, `@MessageHandler`, `@ReactionHandler` and `@Autocomplete` alike. The next major version (5.0) drops
351
- * the inherited routes. Every class a listed controller extends is checked once, a base that is not listed included.
396
+ * the inherited routes. Every class a listed controller extends is checked, a base that is not listed included, for
397
+ * the methods the controller still inherits through it.
352
398
  */ function warnInheritedRoutes(controllerClasses) {
353
- const checked = new Set();
354
- const problems = [];
399
+ // A set, so a class two listed controllers extend is named once, for the routes either of them still answers
400
+ const problems = new Set();
355
401
  for (const controllerClass of controllerClasses){
402
+ // Methods a class below re-declares with @Controller({ inheritedRoutes: 'replace' }): the listed controller inherits
403
+ // nothing of them from above it, so a class above is not named for them
404
+ const cut = new Set();
356
405
  for(let prototype = controllerClass.prototype; prototype && prototype !== Object.prototype; prototype = Object.getPrototypeOf(prototype)){
357
- if (checked.has(prototype)) break;
358
- checked.add(prototype);
359
406
  const declared = controller_decorator.getDeclaredRoutes(prototype);
360
407
  const base = Object.getPrototypeOf(prototype);
361
408
  if (declared.length === 0 || !base) continue;
362
- const inherited = controller_decorator.getHandlerRoutes(base);
409
+ if (Reflect.getOwnMetadata(controller_decorator.META.replacesInheritedRoutes, prototype)) for (const route of declared)cut.add(route.method);
410
+ const inherited = controller_decorator.getHandlerRoutes(base).filter((route)=>!cut.has(route.method));
363
411
  for (const method of new Set(declared.map((route)=>route.method))){
364
412
  const own = [
365
413
  ...new Set(declared.filter((route)=>route.method === method).map((route)=>route.label))
@@ -369,17 +417,19 @@ const quoted = (names)=>names.length < 2 ? names.map((name)=>`"${name}"`).join('
369
417
  ].filter((label)=>!own.includes(label));
370
418
  if (kept.length === 0) continue;
371
419
  const name = prototype.constructor.name;
372
- problems.push(` ${name}.${method} answers ${controller_decorator.listed(kept)}, which it inherits, as well as its own ${controller_decorator.listed(own)}.`);
420
+ problems.add(` ${name}.${method} answers ${controller_decorator.listed(kept)}, which it inherits, as well as its own ${controller_decorator.listed(own)}.`);
373
421
  }
374
422
  }
375
423
  }
376
- if (problems.length === 0) return;
377
- const one = problems.length === 1;
378
- logger.warn(`${problems.length} re-declared ${one ? 'handler still answers routes it inherits' : 'handlers still answer routes they inherit'}:\n` + `${problems.join('\n')}\nIn the next major version (5.0), a handler's own routes replace the ones it inherits. ` + "To keep an inherited route, declare it on the subclass's method as well.");
424
+ if (problems.size === 0) return;
425
+ const one = problems.size === 1;
426
+ logger.warn(`${problems.size} re-declared ${one ? 'handler still answers routes it inherits' : 'handlers still answer routes they inherit'}:\n` + `${[
427
+ ...problems
428
+ ].join('\n')}\nIn the next major version (5.0), a handler's own routes replace the ones it inherits. ` + "To keep an inherited route, declare it on the subclass's method as well; to drop it now, give the subclass " + "@Controller({ inheritedRoutes: 'replace' }).");
379
429
  }
380
430
  /**
381
- * Warns of customId patterns of one component type that can match one customId, naming the handler that runs and why;
382
- * an app whose patterns overlap works, so refusing to start would turn a latent mis-route into an outage.
431
+ * Warns of customId patterns of one component type that rank equally and can match one customId, naming an id both
432
+ * match and the handler that runs. An app with such a pair works, so it starts; the next major version (5.0) refuses.
383
433
  * @throws Error for two handlers whose patterns match the same customIds, which the app refuses as well.
384
434
  */ function warnOverlappingPatterns(controllerClasses) {
385
435
  const routes = buildComponentRoutes(controllerClasses);
@@ -389,23 +439,16 @@ const quoted = (names)=>names.length < 2 ? names.map((name)=>`"${name}"`).join('
389
439
  `${route.meta.type}\0${route.pattern}`,
390
440
  route
391
441
  ]));
392
- const lines = conflicts.map(({ type, patterns: [left, right] })=>{
393
- const outcome = ambiguityOutcome(routeOf.get(`${type}\0${left}`), routeOf.get(`${type}\0${right}`));
394
- return ` "${left}" vs "${right}": ${outcome}`;
395
- });
396
- logger.warn(`${conflicts.length} pattern pair(s) can match the same customId, so which one runs is decided by ranking rather ` + `than by the ids themselves:\n${lines.join('\n')}`);
397
- }
398
- /**
399
- * Which of two overlapping routes runs for the ids both match, and why: `runs` ranks first. Between equally
400
- * specific patterns, the order they are listed in decides until 5.0, which prefers the earlier literal segment.
401
- */ function ambiguityOutcome(runs, other) {
402
442
  const name = ({ controllerClass, meta })=>`${controllerClass.name}.${meta.methodName}`;
403
- if ((runs.meta.specificity ?? 0) !== (other.meta.specificity ?? 0)) return `${name(runs)} runs, as its pattern is more specific.`;
404
- const together = runs.controllerClass === other.controllerClass;
405
- const listed = together ? 'it is declared first' : 'its controller is listed first';
406
- if (!literalFirst(other.pattern, runs.pattern)) return `${name(runs)} runs, as ${listed}.`;
407
- const reorder = together ? `Declare ${name(other)} first` : `List ${other.controllerClass.name} first`;
408
- return `${name(runs)} runs, as ${listed}. In the next major version (5.0), ${name(other)} runs instead, as ` + `"${other.pattern}" spells out the first segment where the two differ. ${reorder}, or make the patterns distinct.`;
443
+ const lines = conflicts.map(({ type, patterns: [left, right], customId })=>{
444
+ const [runs, other] = [
445
+ routeOf.get(`${type}\0${left}`),
446
+ routeOf.get(`${type}\0${right}`)
447
+ ];
448
+ const listed = runs.controllerClass === other.controllerClass ? 'it is declared first' : 'its controller is listed first';
449
+ return ` "${left}" vs "${right}", which both match "${customId}": ${name(runs)} runs, as ${listed}.`;
450
+ });
451
+ logger.warn(`${conflicts.length} pattern pair(s) rank equally and can match the same customId, so the order they are listed in ` + `decides which one runs:\n${lines.join('\n')}\nMake the patterns distinct: in the next major version (5.0), ` + 'MeoCord refuses to start with such a pair.');
409
452
  }
410
453
 
411
454
  const UNKNOWN_INTERACTION = 10062;
@@ -477,7 +520,10 @@ function errorCode(error) {
477
520
  return send(reply.withoutFiles);
478
521
  }
479
522
  }
480
- /** The text of a reply to a message, after the call's `emojis.warning` when `withEmoji`. */ const replyText = (text, withEmoji)=>withEmoji ? `${controller_decorator.useTheme().emojis.warning} ${text}` : text;
523
+ /** 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) {
524
+ const emoji = withEmoji ? `${controller_decorator.useTheme().emojis.warning} ` : '';
525
+ return emoji + controller_decorator.fitText(text, controller_decorator.TEXT_LIMITS.content - emoji.length);
526
+ }
481
527
  /**
482
528
  * `text`, answering `error` for `message`: plain, after the call's `emojis.warning` when `withEmoji`, unless the
483
529
  * presenter's `messageError` draws it as an embed, coloured as an interaction's view is, with that emoji when it has
@@ -485,28 +531,33 @@ function errorCode(error) {
485
531
  * the failure comes back with it, for the caller to report once sent.
486
532
  */ async function presentedReply(message, error, text, withEmoji, logger) {
487
533
  const presenter = controller_decorator.presenterFor(message.client);
534
+ const translator = controller_decorator.translatorOfClient(message.client);
535
+ const locale = controller_decorator.messageLocale(message) ?? translator?.defaultLocale ?? 'en-US';
536
+ // 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
537
+ const shown = text.trim() === '' ? controller_decorator.renderText(translator, locale, {
538
+ key: 'meocord.fallback.error'
539
+ }) : text;
488
540
  const plain = {
489
541
  body: {
490
- content: replyText(text, withEmoji)
542
+ content: replyText(shown, withEmoji)
491
543
  }
492
544
  };
493
545
  const { messageError } = presenter;
494
546
  if (!messageError) return plain;
495
547
  try {
496
548
  const theme = await controller_decorator.themeForInteraction(message);
497
- const translator = controller_decorator.translatorOfClient(message.client);
498
- const locale = controller_decorator.messageLocale(message) ?? translator?.defaultLocale ?? 'en-US';
499
549
  const tone = controller_decorator.isUserOutcome(error, message) ? 'warning' : 'danger';
550
+ const presented = {
551
+ message: controller_decorator.fitText(shown, controller_decorator.TEXT_LIMITS.embed),
552
+ error,
553
+ tone
554
+ };
500
555
  const drawn = await messageError.call(presenter, {
501
556
  message,
502
557
  locale,
503
558
  mode: 'embed',
504
559
  theme
505
- }, {
506
- message: text,
507
- error,
508
- tone
509
- });
560
+ }, presented);
510
561
  let view = drawn.color === undefined ? {
511
562
  ...drawn,
512
563
  color: theme.colors.primary
@@ -563,7 +614,8 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
563
614
  await sendReply(reply, (body)=>message.reply({
564
615
  ...body,
565
616
  allowedMentions: {
566
- repliedUser: false
617
+ repliedUser: false,
618
+ parse: []
567
619
  }
568
620
  }));
569
621
  } catch (replyError) {
@@ -780,6 +832,46 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
780
832
  }
781
833
  }
782
834
 
835
+ /**
836
+ * The calls an app is running, which shutdown waits for before the `onShutdown` hooks. A call that asks the app to
837
+ * stop is excused, so a handler awaiting `app.stop()` never waits for itself.
838
+ */ class RunningCalls {
839
+ /** Runs `run` as a call named `label`, tracked until it settles. */ run(label, run) {
840
+ const token = {};
841
+ this.calls.set(token, label);
842
+ const done = this.current.run(token, run);
843
+ const settle = ()=>{
844
+ this.calls.delete(token);
845
+ this.notify();
846
+ };
847
+ done.then(settle, settle);
848
+ return done;
849
+ }
850
+ /** Excuses the call this runs in, and any it runs within, from the wait; outside a call, nothing. */ excuseCurrent() {
851
+ const token = this.current.getStore();
852
+ if (!token || !this.calls.has(token)) return;
853
+ this.excused.add(token);
854
+ this.notify();
855
+ }
856
+ /** The names of the calls a wait would still wait for. */ running() {
857
+ return [
858
+ ...this.calls
859
+ ].filter(([token])=>!this.excused.has(token)).map(([, label])=>label);
860
+ }
861
+ /** Settles once every call not excused has, those that start or are excused meanwhile included. */ async settled() {
862
+ while(this.running().length > 0)await (this.changed ?? (this.changed = Promise.withResolvers())).promise;
863
+ }
864
+ notify() {
865
+ this.changed?.resolve();
866
+ this.changed = undefined;
867
+ }
868
+ constructor(){
869
+ this.current = new node_async_hooks.AsyncLocalStorage();
870
+ /** Each call under way, by its token, with the name a log gives it. */ this.calls = new Map();
871
+ this.excused = new WeakSet();
872
+ }
873
+ }
874
+
783
875
  /** The entry of a unit whose instance is `instance`. */ function lifecycleEntry(unit, instance) {
784
876
  return {
785
877
  name: unit.name,
@@ -791,11 +883,45 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
791
883
  }
792
884
  };
793
885
  }
886
+ /**
887
+ * The ready hooks' pass, which shutdown waits for before it reads which units to shut down, so a unit whose `onReady`
888
+ * finishes meanwhile is shut down too. An `onReady` still running that asks the app to stop is not waited for; a timer
889
+ * or callback an `onReady` that has settled left behind excuses nothing.
890
+ */ class ReadyPass {
891
+ /** Runs `pass`, the ready hooks, as the pass shutdown waits for. */ run(pass) {
892
+ return this.passes.run('onReady', pass);
893
+ }
894
+ /** Excuses the pass when this runs within the `onReady` in progress, as one that stops the app does. */ excuseCurrent() {
895
+ if (this.current && this.hooks.getStore() === this.current) this.passes.excuseCurrent();
896
+ }
897
+ /** Settles once the pass has, unless excused. */ settled() {
898
+ return this.passes.settled();
899
+ }
900
+ /** The unit whose `onReady` a wait would still wait for, if any. */ running() {
901
+ return this.passes.running().length > 0 ? [
902
+ this.unit ?? 'a unit'
903
+ ] : [];
904
+ }
905
+ constructor(){
906
+ this.passes = new RunningCalls();
907
+ /** The `onReady` each async context belongs to. */ this.hooks = new node_async_hooks.AsyncLocalStorage();
908
+ /** Runs a unit's `onReady` as the one in progress. */ this.runOnReady = async (unit, run)=>{
909
+ const hook = {};
910
+ this.current = hook;
911
+ this.unit = unit.name;
912
+ try {
913
+ await this.hooks.run(hook, run);
914
+ } finally{
915
+ if (this.current === hook) this.current = undefined;
916
+ }
917
+ };
918
+ }
919
+ }
794
920
  /**
795
921
  * Resolves each unit and runs its `onReady` one at a time, in the units' dependency order. A failure is
796
922
  * reported and the next unit still runs. Each unit whose `onReady` settled, or that has none, is pushed
797
923
  * to `entries` as it finishes, so a shutdown that starts meanwhile closes exactly those.
798
- */ async function runReadyHooks(container, units, client, info, entries, report, { stopped = ()=>false, slowAfterMs, settled } = {}) {
924
+ */ async function runReadyHooks(container, units, client, info, entries, report, { stopped = ()=>false, slowAfterMs, settled, runOnReady } = {}) {
799
925
  const failed = new Set();
800
926
  // For each unit, the failed units it depends on, directly or through another dependency
801
927
  const failedUpstream = new Map();
@@ -803,6 +929,9 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
803
929
  unit.token,
804
930
  unit
805
931
  ]));
932
+ // The token each instance was first reached by: one instance two tokens reach, an alias or one value provided twice,
933
+ // runs its hooks once
934
+ const firstToken = new Map();
806
935
  for (const unit of units){
807
936
  if (stopped()) break;
808
937
  const upstream = new Set();
@@ -821,9 +950,17 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
821
950
  settled?.(unit);
822
951
  continue;
823
952
  }
953
+ const first = firstToken.get(instance);
954
+ if (first !== undefined) {
955
+ // Failed or not as the token that ran its hooks did, for what depends on this one
956
+ if (failed.has(first)) failed.add(unit.token);
957
+ settled?.(unit);
958
+ continue;
959
+ }
960
+ firstToken.set(instance, unit.token);
824
961
  const entry = lifecycleEntry(unit, instance);
825
- // Only a unit whose onReady has settled, or that has none, is shut down: a stop mid-ready skips
826
- // the one still starting, and those not reached yet
962
+ // Only a unit whose onReady has settled, or that has none, is shut down: a stop mid-ready waits for the one still
963
+ // starting, within its time, and skips those not reached yet
827
964
  if (typeof instance.onReady !== 'function') {
828
965
  entries.push(entry);
829
966
  settled?.(unit);
@@ -834,7 +971,8 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
834
971
  ]);
835
972
  const slow = slowAfterMs !== undefined && report.slow ? setTimeout(()=>report.slow(unit), slowAfterMs) : undefined;
836
973
  try {
837
- await instance.onReady(client, info);
974
+ const onReady = ()=>instance.onReady(client, info);
975
+ await (runOnReady ? runOnReady(unit, onReady) : onReady());
838
976
  } catch (error) {
839
977
  failed.add(unit.token);
840
978
  report.hookFailed(unit, error);
@@ -847,12 +985,14 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
847
985
  }
848
986
  /**
849
987
  * Runs the entries' `onShutdown` hooks one at a time, in reverse, so a unit stops before those it
850
- * depends on. A failure is reported and the next hook still runs.
851
- */ async function runShutdownHooks(entries, hookFailed) {
988
+ * depends on. A failure is reported and the next hook still runs. An instance already in `ran` is not shut down again,
989
+ * so one that two entries reach stops once.
990
+ */ async function runShutdownHooks(entries, hookFailed, ran = new Set()) {
852
991
  for (const { name, instance } of [
853
992
  ...entries
854
993
  ].reverse()){
855
- if (typeof instance.onShutdown !== 'function') continue;
994
+ if (typeof instance.onShutdown !== 'function' || ran.has(instance)) continue;
995
+ ran.add(instance);
856
996
  try {
857
997
  await instance.onShutdown();
858
998
  } catch (error) {
@@ -860,54 +1000,91 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
860
1000
  }
861
1001
  }
862
1002
  }
863
- /**
864
- * Shuts down the entries there are when it begins, as a bot and a testing module both do. When the cooldown store, or
865
- * anything it injects directly or through another, has an `onShutdown`, the calls under way finish first, then the
866
- * store operations they started, so the store's last writes and releases still reach it. The other hooks then run in
867
- * reverse dependency order, and the store and what it injects last. The whole sequence is waited for at most
868
- * `timeoutMs`.
869
- */ async function runShutdownSequence(container, entries, { drainCalls, timeoutMs, hookFailed, warn }) {
870
- // A unit whose onReady settles while the calls drain was still starting when shutdown began, so it is not shut down
871
- entries = [
872
- ...entries
873
- ];
874
- const byToken = new Map(entries.map((entry)=>[
875
- entry.token,
876
- entry
877
- ]));
878
- // The store and everything it reaches through what it injects
879
- const storeSide = new Set();
880
- const reach = (entry)=>{
881
- if (!entry || storeSide.has(entry)) return;
882
- storeSide.add(entry);
883
- for (const dependency of entry.dependencies)reach(byToken.get(dependency));
884
- };
885
- for (const entry of entries)if (entry.cooldownStore) reach(entry);
886
- const needsStore = [
887
- ...storeSide
888
- ].some((entry)=>typeof entry.instance.onShutdown === 'function');
889
- const sequence = (async ()=>{
890
- if (needsStore) {
891
- await drainCalls();
892
- await providers.storeOperationsSettled(container);
893
- }
894
- // Nothing on the store's side injects anything outside it, so running it last keeps the reverse dependency order
895
- await runShutdownHooks(entries.filter((entry)=>!storeSide.has(entry)), hookFailed);
896
- await runShutdownHooks(entries.filter((entry)=>storeSide.has(entry)), hookFailed);
897
- })();
1003
+ /** The part of `timeoutMs` the drain leaves for the hooks: a quarter, at least 1 s, and never more than half. */ function hooksReserveMs(timeoutMs) {
1004
+ return Math.min(Math.max(timeoutMs / 4, 1000), timeoutMs / 2);
1005
+ }
1006
+ /** A timer whose promise resolves with `value` after `ms`, and `cancel` to clear it. */ function after(ms, value) {
898
1007
  let timer;
899
- const timedOut = new Promise((resolve)=>{
900
- timer = setTimeout(()=>resolve('timeout'), timeoutMs);
1008
+ const promise = new Promise((resolve)=>{
1009
+ timer = setTimeout(()=>resolve(value), ms);
901
1010
  });
1011
+ return {
1012
+ promise,
1013
+ cancel: ()=>clearTimeout(timer)
1014
+ };
1015
+ }
1016
+ /**
1017
+ * Shuts down the entries there are once an `onReady` in progress has settled, as a bot and a testing module both do.
1018
+ * The calls under way finish first, on every shutdown with `drainAlways`, and otherwise when the cooldown store, or
1019
+ * anything it injects directly or through another, has an `onShutdown`; then the store operations they started, so the
1020
+ * store's last writes and releases still reach it. The other hooks then run in reverse dependency order, and the store
1021
+ * and what it injects last.
1022
+ *
1023
+ * The whole sequence is waited for at most `timeoutMs`. The wait for the `onReady` and the drain stop early enough to
1024
+ * leave the hooks {@link hooksReserveMs}, with a warning naming what still runs, and the hooks run in the time left.
1025
+ */ async function runShutdownSequence(container, readEntries, { drainCalls, drainAlways = false, runningCalls, readyPass, timeoutMs, hookFailed, warn }) {
1026
+ const whole = after(timeoutMs, 'timeout');
1027
+ const reserve = hooksReserveMs(timeoutMs);
1028
+ const drainEnds = after(timeoutMs - reserve, false);
1029
+ // At once on a shutdown that always drains, so no call starts while an onReady finishes
1030
+ let draining = drainAlways ? drainCalls().then(()=>true) : undefined;
902
1031
  try {
1032
+ const readying = readyPass?.running() ?? [];
1033
+ if (readying.length > 0 && !await Promise.race([
1034
+ readyPass.settled().then(()=>true),
1035
+ drainEnds.promise
1036
+ ])) {
1037
+ warn(`onReady in ${readying.join(', ')} is still running after ${timeoutMs - reserve} ms; shutting down without waiting for it.`);
1038
+ }
1039
+ // Read once the onReady in progress has settled, so its unit is among them
1040
+ const entries = [
1041
+ ...readEntries()
1042
+ ];
1043
+ const byToken = new Map(entries.map((entry)=>[
1044
+ entry.token,
1045
+ entry
1046
+ ]));
1047
+ // The store and everything it reaches through what it injects
1048
+ const storeSide = new Set();
1049
+ const reach = (entry)=>{
1050
+ if (!entry || storeSide.has(entry)) return;
1051
+ storeSide.add(entry);
1052
+ for (const dependency of entry.dependencies)reach(byToken.get(dependency));
1053
+ };
1054
+ for (const entry of entries)if (entry.cooldownStore) reach(entry);
1055
+ const needsStore = [
1056
+ ...storeSide
1057
+ ].some((entry)=>typeof entry.instance.onShutdown === 'function');
1058
+ let drained = true;
1059
+ if (needsStore) draining ?? (draining = drainCalls().then(()=>true));
1060
+ if (draining) {
1061
+ drained = await Promise.race([
1062
+ draining,
1063
+ drainEnds.promise
1064
+ ]);
1065
+ if (!drained) {
1066
+ const running = runningCalls?.() ?? [];
1067
+ warn(`Calls still running after ${timeoutMs - reserve} ms${running.length > 0 ? `: ${running.join(', ')}` : ''}; ` + `running the onShutdown hooks in the ${reserve} ms left.`);
1068
+ }
1069
+ }
1070
+ drainEnds.cancel();
1071
+ const sequence = (async ()=>{
1072
+ // Only after the calls settled: a call still running may never let its store operations settle
1073
+ if (needsStore && drained) await providers.storeOperationsSettled(container);
1074
+ // Nothing on the store's side injects anything outside it, so running it last keeps the reverse dependency order
1075
+ const ran = new Set();
1076
+ await runShutdownHooks(entries.filter((entry)=>!storeSide.has(entry)), hookFailed, ran);
1077
+ await runShutdownHooks(entries.filter((entry)=>storeSide.has(entry)), hookFailed, ran);
1078
+ })();
903
1079
  if (await Promise.race([
904
1080
  sequence,
905
- timedOut
1081
+ whole.promise
906
1082
  ]) === 'timeout') {
907
1083
  warn(`onShutdown hooks did not finish within ${timeoutMs} ms; shutting down anyway.`);
908
1084
  }
909
1085
  } finally{
910
- clearTimeout(timer);
1086
+ drainEnds.cancel();
1087
+ whole.cancel();
911
1088
  }
912
1089
  }
913
1090
  /** Settles once every promise in `calls` has, those added while waiting included. */ async function callsSettled(calls) {
@@ -930,7 +1107,7 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
930
1107
  */ function matchCommandRoute(controllers, interaction) {
931
1108
  for (const key of commandRouteKeys(interaction)){
932
1109
  for (const controllerClass of controllers){
933
- const meta = controller_decorator.getCommandMap(controllerClass.prototype)?.[key]?.find((each)=>controller_decorator.matchesHandler(each, interaction));
1110
+ const meta = controller_decorator.commandsNamed(controller_decorator.getCommandMap(controllerClass.prototype), key).find((each)=>controller_decorator.matchesHandler(each, interaction));
934
1111
  if (meta) return {
935
1112
  controllerClass,
936
1113
  meta
@@ -954,8 +1131,8 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
954
1131
  for (const [customId, field] of fields){
955
1132
  const data = field;
956
1133
  // An upload's `values` are the attachments' ids; discord.js resolves the attachments themselves alongside
957
- if (data.type === discord_js.ComponentType.FileUpload) values[customId] = entriesOf(interaction.fields.getUploadedFiles(customId));
958
- else values[customId] = 'value' in data ? data.value : data.values;
1134
+ if (data.type === discord_js.ComponentType.FileUpload) controller_decorator.setOwn(values, customId, entriesOf(interaction.fields.getUploadedFiles(customId)));
1135
+ else controller_decorator.setOwn(values, customId, 'value' in data ? data.value : data.values);
959
1136
  }
960
1137
  return values;
961
1138
  }
@@ -1014,7 +1191,7 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
1014
1191
  collisions: []
1015
1192
  };
1016
1193
  const fields = interaction instanceof discord_js.ModalSubmitInteraction ? modalFields(interaction) : selectChoices(interaction);
1017
- const collisions = Object.keys(routeParams).filter((name)=>name in fields);
1194
+ const collisions = Object.keys(routeParams).filter((name)=>Object.hasOwn(fields, name));
1018
1195
  return {
1019
1196
  params: {
1020
1197
  ...fields,
@@ -1100,6 +1277,7 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
1100
1277
  if (state && state !== 'unanswered') return true;
1101
1278
  return 'replied' in interaction && (interaction.replied || interaction.deferred);
1102
1279
  }
1280
+ /** How long a message's handler may take, with others waiting after it, before it is named as slow. */ const SLOW_HANDLER_MS = 5000;
1103
1281
  /**
1104
1282
  * Routes interactions, messages and reactions to the handlers that take them, as the bot does, and runs
1105
1283
  * each through its pipeline. The app and the testing module share it, so a test routes exactly as the bot.
@@ -1355,22 +1533,64 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
1355
1533
  message
1356
1534
  ], error, this.runOptions(call));
1357
1535
  }
1536
+ // The matched handler, then every listener, each resolved from the container only as it starts
1537
+ const runs = [];
1358
1538
  if (target) {
1359
1539
  const { route, params, start, given } = target;
1360
1540
  noteInvocation(message, `${start}${providers.commandWordsOf(route.tokens).join(' ')}`);
1361
1541
  const hooks = providers.messageCommandHooks(route, params, message, start, given, this.messageOptions.types);
1362
- await this.invokeHandler(this.getInstance(route.controllerClass), route.method, [
1363
- message,
1364
- params
1365
- ], call, hooks);
1542
+ runs.push({
1543
+ name: `${route.controllerClass.name}.${route.method}`,
1544
+ run: (at)=>this.invokeHandler(this.getInstance(route.controllerClass), route.method, [
1545
+ message,
1546
+ params
1547
+ ], at, hooks)
1548
+ });
1366
1549
  }
1367
1550
  for (const { controllerClass, method } of this.messageListeners){
1368
- await this.invokeHandler(this.getInstance(controllerClass), method, [
1369
- message
1370
- ], call);
1551
+ runs.push({
1552
+ name: `${controllerClass.name}.${method}`,
1553
+ run: (at)=>this.invokeHandler(this.getInstance(controllerClass), method, [
1554
+ message
1555
+ ], at)
1556
+ });
1557
+ }
1558
+ if (this.messageOptions.handlers === 'concurrent') await this.runTogether(runs, call);
1559
+ else await this.runInTurn(runs, call);
1560
+ }
1561
+ /** Runs a message's handlers one after another, warning once about one that held the rest back for long. */ async runInTurn(runs, call) {
1562
+ const warn = this.messageOptions.slowHandlerWarning ?? this.options.warnSlowMessageHandlers ?? true;
1563
+ for (const [index, { name, run }] of runs.entries()){
1564
+ const started = performance.now();
1565
+ await run(call);
1566
+ const waiting = runs.length - 1 - index;
1567
+ if (warn && waiting > 0) this.warnSlowOnce(name, performance.now() - started, waiting);
1371
1568
  }
1372
1569
  }
1373
1570
  /**
1571
+ * Starts a message's handlers together and settles once all have. Each is reported as settled in the order the
1572
+ * handlers are listed, whichever settles first, and a call that rejects, rejects this once every one has settled.
1573
+ */ async runTogether(runs, call) {
1574
+ const { record } = call;
1575
+ const reports = runs.map(()=>[]);
1576
+ const results = await Promise.allSettled(runs.map(({ run }, index)=>run(record ? {
1577
+ ...call,
1578
+ record: {
1579
+ settled: (...report)=>reports[index].push(report),
1580
+ unhandled: (error)=>record.unhandled(error)
1581
+ }
1582
+ } : call)));
1583
+ for (const report of reports.flat())record?.settled(...report);
1584
+ const failed = results.find((result)=>result.status === 'rejected');
1585
+ if (failed) throw failed.reason;
1586
+ }
1587
+ /** Warns, once per handler, that it took `SLOW_HANDLER_MS` or longer with `waiting` handlers held back behind it. */ warnSlowOnce(name, took, waiting) {
1588
+ if (took < SLOW_HANDLER_MS || this.warnedSlow.has(name)) return;
1589
+ this.warnedSlow.add(name);
1590
+ const held = waiting === 1 ? 'the listener after it' : `the ${waiting} listeners after it`;
1591
+ 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.");
1592
+ }
1593
+ /**
1374
1594
  * Answers a message that asks the built-in help, when `messages.help` is on and no handler took the message:
1375
1595
  * through the presenter's `messageHelp` when it has one, else in plain text, once the app's guards allow it.
1376
1596
  * `known` is the message's starts when they hold the app's prefixes already. Whether it answered, or a
@@ -1491,6 +1711,7 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
1491
1711
  this.controllerInstancesCache = new Map();
1492
1712
  /** Handler and name pairs already warned about, so a collision warns once, not on every submit or selection. */ this.warnedCollisions = new Set();
1493
1713
  /** The handlers already warned about, so each is named once however often it runs. */ this.warnedUnanswered = new Set();
1714
+ /** The message handlers already warned about as slow, so each is named once. */ this.warnedSlow = new Set();
1494
1715
  this.container = options.container;
1495
1716
  this.controllerClasses = options.controllerClasses;
1496
1717
  this.messageOptions = options.messageOptions ?? {};
@@ -1582,6 +1803,24 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
1582
1803
  for (const [stage, decorator] of found)decorators.set(stage, decorator);
1583
1804
  }
1584
1805
  }
1806
+ /** Whether making `cls` reaches the per-call `ExecutionContext` through the classes it injects, which each call makes. */ function reachesContext(cls, providers$1, seen = new Set()) {
1807
+ seen.add(cls);
1808
+ return providers.injectedTokens(cls).some((token)=>token === controller_decorator.ExecutionContext || providers.isAppClassToken(token) && !providers$1.has(token) && !seen.has(token) && reachesContext(token, providers$1, seen));
1809
+ }
1810
+ /**
1811
+ * The classes the app's stages and presenter inject, which they would make only at their first call. Made with the
1812
+ * app's own classes, a service among them gets its lifecycle hooks. One that reaches the call's `ExecutionContext` is
1813
+ * made for each call, so it is left out, and a stage a test replaces is not among the stages, so what it would inject
1814
+ * is not made.
1815
+ */ function stageDependencies({ decorators }, providers$1, presenter) {
1816
+ const stages = [
1817
+ ...decorators
1818
+ ].filter(([, decorator])=>decorator !== '@Controller()').map(([stage])=>stage);
1819
+ if (typeof presenter === 'function') stages.push(presenter);
1820
+ return [
1821
+ ...new Set(stages.flatMap((stage)=>providers.injectedTokens(stage)))
1822
+ ].filter(providers.isAppClassToken).filter((cls)=>!reachesContext(cls, providers$1));
1823
+ }
1585
1824
  /** How a container checks a guard first met at a call: the guard and what it injects, as the startup checks would. */ function lateGuardCheck(providers$1, translator) {
1586
1825
  return (guard)=>assertStartupClasses({
1587
1826
  classes: providers.reachableClasses([
@@ -1600,12 +1839,13 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
1600
1839
  /** Runs the startup checks over every class an app runs, so a class that cannot be made is refused by name now. */ function assertStartupClasses({ classes, decorators }, { translator }) {
1601
1840
  providers.assertTypedParameters(classes, decorators);
1602
1841
  if (translator) return;
1603
- const needsOne = classes.find((cls)=>providers.injectedTokens(cls).includes(controller_decorator.Translator));
1604
- if (needsOne) throw controller_decorator.refuse(controller_decorator.missingTranslatorError(needsOne));
1842
+ for (const needsOne of classes.filter((cls)=>providers.injectedTokens(cls).includes(controller_decorator.Translator)))controller_decorator.startupError(controller_decorator.missingTranslatorError(needsOne));
1605
1843
  }
1606
1844
 
1607
1845
  exports.DEFAULT_SHUTDOWN_TIMEOUT_MS = DEFAULT_SHUTDOWN_TIMEOUT_MS;
1608
1846
  exports.Dispatcher = Dispatcher;
1847
+ exports.ReadyPass = ReadyPass;
1848
+ exports.RunningCalls = RunningCalls;
1609
1849
  exports.SHUTDOWN_MARGIN_MS = SHUTDOWN_MARGIN_MS;
1610
1850
  exports.assertDistinctCommands = assertDistinctCommands;
1611
1851
  exports.assertStartupClasses = assertStartupClasses;
@@ -1622,11 +1862,13 @@ exports.matchComponentRoute = matchComponentRoute;
1622
1862
  exports.messageDecorator = messageDecorator;
1623
1863
  exports.ownInteractionListener = ownInteractionListener;
1624
1864
  exports.reactionDecorator = reactionDecorator;
1865
+ exports.readCustomId = readCustomId;
1625
1866
  exports.replyWithUserError = replyWithUserError;
1626
1867
  exports.runReadyHooks = runReadyHooks;
1627
1868
  exports.runShutdownSequence = runShutdownSequence;
1628
1869
  exports.shutdownTimeoutOf = shutdownTimeoutOf;
1629
1870
  exports.shutdownTimeoutProblem = shutdownTimeoutProblem;
1871
+ exports.stageDependencies = stageDependencies;
1630
1872
  exports.startupClasses = startupClasses;
1631
1873
  exports.warnHandlersOffControllers = warnHandlersOffControllers;
1632
1874
  exports.warnInheritedRoutes = warnInheritedRoutes;