meocord 4.2.1 → 4.2.3

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 (137) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/dist/cjs/_shared/{controller.decorator-XKMlcUZz.cjs → controller.decorator-hp5xdJU4.cjs} +550 -189
  3. package/dist/cjs/_shared/{startup-roots-CgfPQrNl.cjs → event-sources-DzqTODXg.cjs} +274 -88
  4. package/dist/cjs/_shared/{providers-BZgJyfZS.cjs → providers-BBvbnmLP.cjs} +837 -295
  5. package/dist/cjs/_shared/{sharded-cooldown-store-CsJJyCZd.cjs → sharded-cooldown-store-B62g5O88.cjs} +11 -9
  6. package/dist/cjs/_shared/{translate-error-BUAR1WYG.cjs → translate-error-wsgfsUET.cjs} +1 -1
  7. package/dist/cjs/common/index.cjs +4 -104
  8. package/dist/cjs/core/index.cjs +435 -182
  9. package/dist/cjs/decorator/index.cjs +116 -121
  10. package/dist/cjs/testing/index.cjs +1923 -808
  11. package/dist/cli.json +1 -1
  12. package/dist/esm/bin/app-template/package.json.template +9 -9
  13. package/dist/esm/bin/app-template/src/main.ts.template +3 -2
  14. package/dist/esm/bin/generator.js +6 -4
  15. package/dist/esm/bin/helper/filter-generator.helper.js +6 -4
  16. package/dist/esm/bin/helper/guard-generator.helper.js +6 -4
  17. package/dist/esm/bin/helper/interceptor-generator.helper.js +6 -4
  18. package/dist/esm/bin/helper/observer-generator.helper.js +6 -4
  19. package/dist/esm/bin/helper/pipe-generator.helper.js +6 -4
  20. package/dist/esm/bin/helper/service-generator.helper.js +6 -4
  21. package/dist/esm/bin/meocord.js +106 -48
  22. package/dist/esm/build/native-addons.js +75 -19
  23. package/dist/esm/build/rsbuild-config.js +11 -3
  24. package/dist/esm/build/typed-externals.js +15 -2
  25. package/dist/esm/common/cooldown-store.js +38 -14
  26. package/dist/esm/common/deprecation.js +4 -1
  27. package/dist/esm/common/execution-context.js +3 -1
  28. package/dist/esm/common/explained-error.js +7 -3
  29. package/dist/esm/common/log-level.js +13 -9
  30. package/dist/esm/common/logger.js +4 -2
  31. package/dist/esm/common/meocord-text.js +11 -6
  32. package/dist/esm/common/redis-cooldown-store.js +3 -7
  33. package/dist/esm/common/response/flags.js +17 -1
  34. package/dist/esm/common/response/install-context.js +27 -16
  35. package/dist/esm/common/response/presenter.js +7 -3
  36. package/dist/esm/common/response/response-state.js +32 -10
  37. package/dist/esm/common/sharded-cooldown-store.js +11 -9
  38. package/dist/esm/common/theme.js +3 -1
  39. package/dist/esm/common/translator.js +27 -8
  40. package/dist/esm/core/autocomplete-routes.js +26 -0
  41. package/dist/esm/core/command-conflicts.js +15 -7
  42. package/dist/esm/core/command-registration.js +15 -1
  43. package/dist/esm/core/cooldown-runner.js +7 -9
  44. package/dist/esm/core/defer.js +2 -5
  45. package/dist/esm/core/dispatcher.js +92 -66
  46. package/dist/esm/core/event-requirements.js +67 -19
  47. package/dist/esm/core/event-sources.js +100 -0
  48. package/dist/esm/core/fallback.js +3 -1
  49. package/dist/esm/core/filter-runner.js +6 -6
  50. package/dist/esm/core/guard-runner.js +173 -52
  51. package/dist/esm/core/handler-input.js +24 -3
  52. package/dist/esm/core/handler-pipeline.js +54 -32
  53. package/dist/esm/core/handler-registry.js +51 -21
  54. package/dist/esm/core/input-runner.js +7 -8
  55. package/dist/esm/core/interceptor-runner.js +4 -5
  56. package/dist/esm/core/lifecycle-hooks.js +3 -8
  57. package/dist/esm/core/lifecycle-order.js +1 -15
  58. package/dist/esm/core/meocord-classes.js +14 -1
  59. package/dist/esm/core/meocord-factory.js +83 -54
  60. package/dist/esm/core/meocord.app.js +174 -89
  61. package/dist/esm/core/message-help.js +37 -15
  62. package/dist/esm/core/message-params.js +126 -46
  63. package/dist/esm/core/message-routes.js +225 -45
  64. package/dist/esm/core/message-words.js +30 -2
  65. package/dist/esm/core/observer-runner.js +4 -2
  66. package/dist/esm/core/process-state.js +19 -0
  67. package/dist/esm/core/providers.js +39 -4
  68. package/dist/esm/core/scalar-types.js +57 -16
  69. package/dist/esm/core/shard-context.js +11 -10
  70. package/dist/esm/core/shard-exit.js +3 -3
  71. package/dist/esm/core/shard-manager.js +16 -6
  72. package/dist/esm/core/stage-scope.js +2 -1
  73. package/dist/esm/core/startup-roots.js +28 -8
  74. package/dist/esm/core/theme-resolvers.js +26 -11
  75. package/dist/esm/core/theme-runtime.js +23 -13
  76. package/dist/esm/core/theme-scope.js +31 -30
  77. package/dist/esm/core/theme-validation.js +1 -1
  78. package/dist/esm/decorator/app.decorator.js +37 -1
  79. package/dist/esm/decorator/command-builder.decorator.js +14 -0
  80. package/dist/esm/decorator/controller-class.decorator.js +1 -1
  81. package/dist/esm/decorator/controller.decorator.js +48 -19
  82. package/dist/esm/decorator/defer.decorator.js +2 -2
  83. package/dist/esm/decorator/event.decorator.js +4 -5
  84. package/dist/esm/decorator/filter.decorator.js +4 -4
  85. package/dist/esm/decorator/guard.decorator.js +4 -61
  86. package/dist/esm/decorator/inject.decorator.js +4 -1
  87. package/dist/esm/decorator/interceptor.decorator.js +2 -2
  88. package/dist/esm/decorator/theme.decorator.js +4 -3
  89. package/dist/esm/decorator/validation.decorator.js +5 -5
  90. package/dist/esm/package.json.js +1 -1
  91. package/dist/esm/testing/catalog.js +32 -25
  92. package/dist/esm/testing/cooldown-store-suite.js +3 -3
  93. package/dist/esm/testing/discord-defaults.js +449 -335
  94. package/dist/esm/testing/inspect-handler.js +91 -10
  95. package/dist/esm/testing/meocord-testing-module.js +217 -91
  96. package/dist/esm/testing/mock-fn.js +8 -2
  97. package/dist/esm/testing/mock-interaction.js +864 -124
  98. package/dist/esm/testing/response-log.js +8 -0
  99. package/dist/esm/testing/response.js +7 -5
  100. package/dist/esm/testing/routing.js +48 -6
  101. package/dist/esm/testing/strict-mocks.js +8 -4
  102. package/dist/esm/testing/theme.js +3 -2
  103. package/dist/esm/util/common.util.js +30 -9
  104. package/dist/esm/util/generator-cli.util.js +6 -4
  105. package/dist/esm/util/inherited-env.util.js +72 -13
  106. package/dist/esm/util/json.util.js +10 -3
  107. package/dist/esm/util/meocord-cli.util.js +3 -2
  108. package/dist/esm/util/meocord-config-loader.util.js +14 -1
  109. package/dist/esm/util/meocord-config-validation.util.js +13 -3
  110. package/dist/esm/util/meocord-source-config.util.js +13 -3
  111. package/dist/esm/util/platform.util.js +3 -2
  112. package/dist/esm/util/refusal.util.js +29 -11
  113. package/dist/esm/util/registration-mode.util.js +14 -1
  114. package/dist/esm/util/timer-limit.util.js +4 -1
  115. package/dist/esm/util/tsconfig-paths.util.js +38 -7
  116. package/dist/esm/util/tsconfig.util.js +16 -58
  117. package/dist/esm/util/user-text.util.js +8 -4
  118. package/dist/esm/util/value.util.js +33 -5
  119. package/dist/types/common/index.d.cts +17 -10
  120. package/dist/types/common/index.d.ts +17 -10
  121. package/dist/types/core/index.d.cts +21 -11
  122. package/dist/types/core/index.d.ts +21 -11
  123. package/dist/types/decorator/index.d.cts +63 -63
  124. package/dist/types/decorator/index.d.ts +63 -63
  125. package/dist/types/{index-DKJclIi-.d.ts → index-Brkd6QGL.d.ts} +81 -35
  126. package/dist/types/{index-Dlf0ZZtS.d.cts → index-CqXImwF_.d.cts} +81 -35
  127. package/dist/types/interface/index.d.cts +1 -1
  128. package/dist/types/interface/index.d.ts +1 -1
  129. package/dist/types/{route-Bh1U13yy.d.ts → route-IQrK-MN2.d.cts} +11 -3
  130. package/dist/types/{route-Bh1U13yy.d.cts → route-bI-okq7g.d.ts} +11 -3
  131. package/dist/types/testing/index.d.cts +158 -61
  132. package/dist/types/testing/index.d.ts +158 -61
  133. package/dist/types/{theme-resolvers-CyEz9pnv.d.cts → theme-resolvers-AtgeaYqF.d.cts} +1 -1
  134. package/dist/types/{theme-resolvers-DA204O-q.d.ts → theme-resolvers-BgqysV8d.d.ts} +1 -1
  135. package/dist/types/{translator-Bg7hwxDF.d.cts → translator-D8aydlCE.d.ts} +12 -11
  136. package/dist/types/{translator-CeP4bCzp.d.ts → translator-iJhDlgwL.d.cts} +12 -11
  137. package/package.json +5 -2
@@ -1,5 +1,6 @@
1
- import { getAutocompleteHandlers, getReactionHandlers, getMessageHandlers, matchesEmoji } from '../decorator/controller.decorator.js';
2
- import { hasCustomId, describeInteraction, focusedOptionName, resolveCommandPaths, resolveOptionParams, matchesCommandType } from '../util/interaction.util.js';
1
+ import { getReactionHandlers, getMessageHandlers, matchesEmoji } from '../decorator/controller.decorator.js';
2
+ import { hasCustomId, describeInteraction, resolveOptionParams, matchesCommandType } from '../util/interaction.util.js';
3
+ import { buildAutocompleteRoutes, matchAutocompleteRoute } from './autocomplete-routes.js';
3
4
  import { buildComponentRoutes, matchComponentRoute } from './component-routes.js';
4
5
  import { handleUnroutedError, observeUnclaimed, runHandler, runInAppTheme, globalStagesOf, appPresenterOf } from './handler-pipeline.js';
5
6
  import { helpWords, isListable, matchHelpRequest, computeMessageHelp, helpInvocation, renderMessageHelp, splitReply } from './message-help.js';
@@ -12,7 +13,7 @@ import { handlerInput } from './handler-input.js';
12
13
  import { matchCommandRoute } from './command-routes.js';
13
14
  import { runGuards } from './guard-runner.js';
14
15
  import { HandlerRegistry, shareMessageRoutes } from './handler-registry.js';
15
- import { matchMessageRoute, matchMessageCommand, messageStartsFor, usesAppPrefix, matchMessageSubcommands, commandWordsOf, messageStarts, buildMessageRoutes } from './message-routes.js';
16
+ import { matchMessageRoute, matchMessageCommand, hasMessageText, messageStartsFor, usesAppPrefix, matchMessageSubcommands, commandWordsOf, messageStarts, buildMessageRoutes, messageRouteWarnings } from './message-routes.js';
16
17
  import { subcommandUsageError, messageCommandHooks } from './message-params.js';
17
18
  import { CommandNotFoundError } from '../common/errors.js';
18
19
  import { existingResponse } from '../common/response/response-state.js';
@@ -83,13 +84,19 @@ import { existingResponse } from '../common/response/response-state.js';
83
84
  this.logger.warn(`messages.help is on, but ${route.controllerClass.name}.${route.method} (${JSON.stringify(route.pattern)}) takes "${shown}", ` + `which runs it instead, so the built-in help never answers. Turn help off, or give the handler another word.`);
84
85
  }
85
86
  }
86
- /** The call for one event: the fallback, told of each error that reaches it when there is a recorder. */ callFor(record, startedAt) {
87
+ /**
88
+ * The call for one event, stamped as it is made, so every handler it runs reports one start and a duration that
89
+ * includes routing and any handler that ran before it; the fallback is told of each error that reaches it when there
90
+ * is a recorder.
91
+ */ callFor(record) {
87
92
  const fallback = record ? async (error, context)=>{
88
93
  record.unhandled(error);
89
- return this.fallback(error, context);
94
+ const failure = await this.fallback(error, context);
95
+ if (failure !== undefined) record.unanswered(error, failure);
96
+ return failure;
90
97
  } : this.fallback;
91
98
  return {
92
- startedAt,
99
+ startedAt: performance.now(),
93
100
  fallback,
94
101
  record
95
102
  };
@@ -100,32 +107,35 @@ import { existingResponse } from '../common/response/response-state.js';
100
107
  }
101
108
  return this.controllerInstancesCache.get(controllerClass);
102
109
  }
110
+ /**
111
+ * Runs `run` on the controller's instance, resolved as its handler starts. A controller that cannot be made fails
112
+ * only its own handlers: the error goes to the global filters, then the fallback, and the call goes on without it.
113
+ */ async withInstance(controllerClass, args, call, run, unresolved = new Set()) {
114
+ // A controller that failed to resolve for this event is reported once, however many of its handlers it reaches
115
+ if (unresolved.has(controllerClass)) return;
116
+ let instance;
117
+ try {
118
+ instance = this.getInstance(controllerClass);
119
+ } catch (error) {
120
+ unresolved.add(controllerClass);
121
+ await handleUnroutedError(this.container, args, error, this.runOptions(call));
122
+ return;
123
+ }
124
+ await run(instance);
125
+ }
103
126
  /** The component routes, built on first use; the app calls it as it is created. */ getComponentRoutes() {
104
127
  return this.componentRoutes ?? (this.componentRoutes = buildComponentRoutes([
105
128
  ...this.controllerClasses
106
129
  ]));
107
130
  }
108
131
  getAutocompleteRoutes() {
109
- if (this.autocompleteRoutes) return this.autocompleteRoutes;
110
- const routes = [];
111
- for (const controllerClass of this.controllerClasses){
112
- for (const meta of getAutocompleteHandlers(this.getInstance(controllerClass))){
113
- routes.push({
114
- controllerClass,
115
- meta
116
- });
117
- }
118
- }
119
- routes.sort((a, b)=>Number(Boolean(b.meta.optionName)) - Number(Boolean(a.meta.optionName)));
120
- this.autocompleteRoutes = routes;
121
- return routes;
132
+ return this.autocompleteRoutes ?? (this.autocompleteRoutes = buildAutocompleteRoutes(this.controllerClasses));
122
133
  }
123
134
  /**
124
135
  * Dispatches an interaction. A failure outside any handler, such as no route matching or resolving
125
136
  * the controller, goes to the global filters, then the fallback.
126
137
  */ async interaction(interaction, record) {
127
- // From the moment it arrives, so an observer's duration includes routing
128
- const call = this.callFor(record, performance.now());
138
+ const call = this.callFor(record);
129
139
  try {
130
140
  await this.dispatchInteraction(interaction, call);
131
141
  } catch (error) {
@@ -151,14 +161,14 @@ import { existingResponse } from '../common/response/response-state.js';
151
161
  if (matched) {
152
162
  const { route, params } = matched;
153
163
  interaction.dynamicParams = params;
154
- await this.executeCommand(this.getInstance(route.controllerClass), route.meta, interaction, call);
164
+ await this.executeCommand(route.controllerClass, route.meta, interaction, call);
155
165
  return;
156
166
  }
157
167
  }
158
168
  // The same match a test's invoke checks a handler against, so the two cannot disagree
159
169
  const command = matchCommandRoute(this.controllerClasses, interaction);
160
170
  if (command) {
161
- await this.executeCommand(this.getInstance(command.controllerClass), command.meta, interaction, call);
171
+ await this.executeCommand(command.controllerClass, command.meta, interaction, call);
162
172
  return;
163
173
  }
164
174
  // A component or modal no route takes may be a collector's: with another listener on the client, it has the
@@ -181,21 +191,17 @@ import { existingResponse } from '../common/response/response-state.js';
181
191
  * Answers an autocomplete interaction from the `@Autocomplete` handler that claims it. An unclaimed
182
192
  * option gets an empty list and a warning, rather than a menu stuck loading until Discord times out.
183
193
  */ async handleAutocomplete(interaction, call) {
184
- const focusedName = focusedOptionName(interaction);
185
- for (const path of resolveCommandPaths(interaction)){
186
- for (const { controllerClass, meta } of this.getAutocompleteRoutes()){
187
- if (meta.commandPath !== path) continue;
188
- if (meta.optionName !== undefined && meta.optionName !== focusedName) continue;
189
- const controllerInstance = this.getInstance(controllerClass);
190
- this.logger.log('[AUTOCOMPLETE]', `[${path}]`, `[${meta.methodName}]`);
191
- const params = resolveOptionParams(interaction);
192
- const ran = await this.invokeHandler(controllerInstance, meta.methodName, [
193
- interaction,
194
- params
195
- ], call);
196
- if (!ran) await closeAutocomplete(interaction, this.logger);
197
- return;
198
- }
194
+ const route = matchAutocompleteRoute(this.getAutocompleteRoutes(), interaction);
195
+ if (route) {
196
+ const { controllerClass, meta } = route;
197
+ this.logger.log('[AUTOCOMPLETE]', `[${meta.commandPath}]`, `[${meta.methodName}]`);
198
+ const params = resolveOptionParams(interaction);
199
+ const ran = await this.invokeHandler(this.getInstance(controllerClass), meta.methodName, [
200
+ interaction,
201
+ params
202
+ ], call);
203
+ if (!ran) await closeAutocomplete(interaction, this.logger);
204
+ return;
199
205
  }
200
206
  this.logger.warn(`No handler matched ${describeInteraction(interaction)}. Declare an @Autocomplete handler for it, ` + `or drop setAutocomplete(true) from the option.`);
201
207
  await closeAutocomplete(interaction, this.logger);
@@ -203,19 +209,22 @@ import { existingResponse } from '../common/response/response-state.js';
203
209
  interaction
204
210
  ], this.runOptions(call));
205
211
  }
206
- /** Warns, in development, that a modal field or select menu choice is hidden by a customId param of the same name. */ warnCollisions(methodName, names) {
212
+ /** Warns, in development, that a modal field or select menu choice is hidden by a customId param of the same name. */ warnCollisions(controllerClass, methodName, names) {
207
213
  if (process.env.NODE_ENV !== 'development') return;
214
+ let warned = this.warnedCollisions.get(controllerClass);
215
+ if (!warned) this.warnedCollisions.set(controllerClass, warned = new Set());
208
216
  for (const name of names){
209
217
  const key = `${methodName}:${name}`;
210
- if (this.warnedCollisions.has(key)) continue;
211
- this.warnedCollisions.add(key);
212
- this.logger.warn(`"${name}" is both a customId param and a modal field or select menu choice of ${methodName}; the handler receives the customId ` + `param. Rename one to receive both.`);
218
+ if (warned.has(key)) continue;
219
+ warned.add(key);
220
+ this.logger.warn(`"${name}" is both a customId param and a modal field or select menu choice of ${controllerClass.name}.${methodName}; the handler ` + `receives the customId param. Rename one to receive both.`);
213
221
  }
214
222
  }
215
223
  /**
216
224
  * Runs a resolved command, shared by both dispatch paths so a pattern-matched
217
225
  * component and a named slash command behave identically once the route is chosen.
218
- */ async executeCommand(controllerInstance, commandMetadata, interaction, call) {
226
+ */ async executeCommand(controllerClass, commandMetadata, interaction, call) {
227
+ const controllerInstance = this.getInstance(controllerClass);
219
228
  const { methodName, type } = commandMetadata;
220
229
  // No interaction-type check here: both callers pick the route with
221
230
  // `matchesCommandType` before getting this far, and `@Command` re-checks the
@@ -223,7 +232,7 @@ import { existingResponse } from '../common/response/response-state.js';
223
232
  this.logger.log('[INTERACTION]', `[${type}]`, `[${methodName}]`);
224
233
  const routeParams = interaction.dynamicParams;
225
234
  const { params, collisions } = handlerInput(interaction, routeParams);
226
- this.warnCollisions(methodName, collisions);
235
+ this.warnCollisions(controllerClass, methodName, collisions);
227
236
  await this.invokeHandler(controllerInstance, methodName, [
228
237
  interaction,
229
238
  params
@@ -241,10 +250,7 @@ import { existingResponse } from '../common/response/response-state.js';
241
250
  * says whether the handler ran. Its guard wrappers let this call through, so each guard runs once.
242
251
  */ async invokeHandler(instance, methodName, args, call, hooks = {}) {
243
252
  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;
253
+ const onUnanswered = this.options.warnUnanswered ? (unanswered)=>this.warnUnansweredOnce(handler, unanswered) : undefined;
248
254
  const outcome = await runHandler(this.container, instance, methodName, args, {
249
255
  ...this.runOptions(call),
250
256
  onUnanswered,
@@ -254,13 +260,17 @@ import { existingResponse } from '../common/response/response-state.js';
254
260
  return outcome.ran;
255
261
  }
256
262
  /**
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) {
263
+ * Warns, once per handler, that it left its interaction or autocomplete unanswered or deferred without a follow-up,
264
+ * or that an interceptor returned before the handler ran or finished, and the call ended so.
265
+ */ warnUnansweredOnce(handler, { type, phase, returnedBy }) {
260
266
  if (this.warnedUnanswered.has(handler)) return;
261
267
  this.warnedUnanswered.add(handler);
268
+ const interceptor = returnedBy && {
269
+ name: returnedBy.interceptor.name,
270
+ handlerStarted: returnedBy.handlerStarted
271
+ };
262
272
  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().';
273
+ 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
274
  this.logger.warn(`${what} Shown once per handler; @MeoCord({ warnUnanswered: false }) turns it off.`);
265
275
  }
266
276
  /**
@@ -269,7 +279,7 @@ import { existingResponse } from '../common/response/response-state.js';
269
279
  * usage through that handler's filters. A bare parent's unguarded subcommands' usage, once the app's guards allow
270
280
  * it, and a failure to read the prefixes, go through the global filters, then the fallback; the listeners still run.
271
281
  */ async message(message, record) {
272
- if (message.author.bot || !message.content?.trim()) return;
282
+ if (message.author.bot || !hasMessageText(message.content)) return;
273
283
  const call = this.callFor(record);
274
284
  let target;
275
285
  try {
@@ -296,24 +306,29 @@ import { existingResponse } from '../common/response/response-state.js';
296
306
  }
297
307
  // The matched handler, then every listener, each resolved from the container only as it starts
298
308
  const runs = [];
309
+ const unresolved = new Set();
299
310
  if (target) {
300
311
  const { route, params, start, given } = target;
301
312
  noteInvocation(message, `${start}${commandWordsOf(route.tokens).join(' ')}`);
302
313
  const hooks = messageCommandHooks(route, params, message, start, given, this.messageOptions.types);
303
314
  runs.push({
304
315
  name: `${route.controllerClass.name}.${route.method}`,
305
- run: (at)=>this.invokeHandler(this.getInstance(route.controllerClass), route.method, [
306
- message,
307
- params
308
- ], at, hooks)
316
+ run: (at)=>this.withInstance(route.controllerClass, [
317
+ message
318
+ ], at, (instance)=>this.invokeHandler(instance, route.method, [
319
+ message,
320
+ params
321
+ ], at, hooks), unresolved)
309
322
  });
310
323
  }
311
324
  for (const { controllerClass, method } of this.messageListeners){
312
325
  runs.push({
313
326
  name: `${controllerClass.name}.${method}`,
314
- run: (at)=>this.invokeHandler(this.getInstance(controllerClass), method, [
327
+ run: (at)=>this.withInstance(controllerClass, [
315
328
  message
316
- ], at)
329
+ ], at, (instance)=>this.invokeHandler(instance, method, [
330
+ message
331
+ ], at), unresolved)
317
332
  });
318
333
  }
319
334
  if (this.messageOptions.handlers === 'concurrent') await this.runTogether(runs, call);
@@ -338,7 +353,8 @@ import { existingResponse } from '../common/response/response-state.js';
338
353
  ...call,
339
354
  record: {
340
355
  settled: (...report)=>reports[index].push(report),
341
- unhandled: (error)=>record.unhandled(error)
356
+ unhandled: (error)=>record.unhandled(error),
357
+ unanswered: (error, failure)=>record.unanswered(error, failure)
342
358
  }
343
359
  } : call)));
344
360
  for (const report of reports.flat())record?.settled(...report);
@@ -418,7 +434,7 @@ import { existingResponse } from '../common/response/response-state.js';
418
434
  const matching = this.controllerClasses.map((controllerClass)=>({
419
435
  controllerClass,
420
436
  // Handlers for this emoji first, then those for every emoji
421
- handlers: getReactionHandlers(this.getInstance(controllerClass)).filter(forEmoji).sort((a, b)=>Number(!a.emoji) - Number(!b.emoji))
437
+ handlers: getReactionHandlers(controllerClass.prototype).filter(forEmoji).sort((a, b)=>Number(!a.emoji) - Number(!b.emoji))
422
438
  })).filter(({ handlers })=>handlers.length > 0);
423
439
  if (matching.length === 0) return;
424
440
  // Only asked when a matching handler leaves bots out, since a partial user costs a fetch
@@ -442,16 +458,21 @@ import { existingResponse } from '../common/response/response-state.js';
442
458
  return;
443
459
  }
444
460
  for (const { controllerClass, handlers } of runs){
445
- const controllerInstance = this.getInstance(controllerClass);
446
- for (const { method } of handlers){
447
- await this.invokeHandler(controllerInstance, method, [
461
+ await this.withInstance(controllerClass, [
462
+ reaction,
463
+ {
464
+ user,
465
+ action
466
+ }
467
+ ], call, async (instance)=>{
468
+ for (const { method } of handlers)await this.invokeHandler(instance, method, [
448
469
  reaction,
449
470
  {
450
471
  user,
451
472
  action
452
473
  }
453
474
  ], call);
454
- }
475
+ });
455
476
  }
456
477
  }
457
478
  /**
@@ -470,7 +491,10 @@ import { existingResponse } from '../common/response/response-state.js';
470
491
  constructor(options){
471
492
  this.options = options;
472
493
  this.controllerInstancesCache = new Map();
473
- /** Handler and name pairs already warned about, so a collision warns once, not on every submit or selection. */ this.warnedCollisions = new Set();
494
+ /**
495
+ * Method and name pairs already warned about, by controller class, so a collision warns once per handler, not on
496
+ * every submit or selection. Keyed by the class itself, so two controllers of one name stay apart.
497
+ */ this.warnedCollisions = new WeakMap();
474
498
  /** The handlers already warned about, so each is named once however often it runs. */ this.warnedUnanswered = new Set();
475
499
  /** The message handlers already warned about as slow, so each is named once. */ this.warnedSlow = new Set();
476
500
  this.container = options.container;
@@ -485,6 +509,8 @@ import { existingResponse } from '../common/response/response-state.js';
485
509
  // The app's own help command reads this table too, so it lists the commands dispatch reaches
486
510
  if (this.container.isBound(HandlerRegistry)) shareMessageRoutes(this.container.get(HandlerRegistry), this.messageRoutes);
487
511
  this.warnUnreachableHelp();
512
+ // Here, where the routes are built once, rather than in buildMessageRoutes, which startup runs twice
513
+ for (const warning of messageRouteWarnings(this.messageRoutes, this.messageOptions))this.logger.warn(warning);
488
514
  this.messageListeners = this.controllerClasses.flatMap((controllerClass)=>getMessageHandlers(controllerClass.prototype).filter((handler)=>handler.pattern === undefined).map(({ method })=>({
489
515
  controllerClass,
490
516
  method
@@ -13,6 +13,22 @@ const REACTION_PARTIALS = [
13
13
  Partials.Message,
14
14
  Partials.Reaction
15
15
  ];
16
+ const POLLS = [
17
+ GatewayIntentBits.GuildMessagePolls,
18
+ GatewayIntentBits.DirectMessagePolls
19
+ ];
20
+ // discord.js drops a vote on a message it has not cached unless it may build the message, its poll and the answer
21
+ const POLL_PARTIALS = [
22
+ Partials.Message,
23
+ Partials.Poll,
24
+ Partials.PollAnswer
25
+ ];
26
+ // The partials whose warning names members and polls; the others keep the wording for messages, reactions and users
27
+ const CACHED_ELSEWHERE = new Set([
28
+ Partials.GuildMember,
29
+ Partials.Poll,
30
+ Partials.PollAnswer
31
+ ]);
16
32
  /** The intents Discord only sends once they are also enabled for the app in the developer portal. */ const PRIVILEGED = new Set([
17
33
  GatewayIntentBits.GuildMembers,
18
34
  GatewayIntentBits.GuildPresences,
@@ -50,10 +66,9 @@ const REACTION_PARTIALS = [
50
66
  ]
51
67
  }
52
68
  ])),
69
+ // A member that joins arrives whole; one that leaves or changes reaches its handler only if cached or with this partial
53
70
  ...Object.fromEntries([
54
71
  'guildMemberAdd',
55
- 'guildMemberRemove',
56
- 'guildMemberUpdate',
57
72
  'guildMemberAvailable',
58
73
  'threadMembersUpdate'
59
74
  ].map((event)=>[
@@ -66,6 +81,26 @@ const REACTION_PARTIALS = [
66
81
  ]
67
82
  }
68
83
  ])),
84
+ guildMemberRemove: {
85
+ intents: [
86
+ [
87
+ GatewayIntentBits.GuildMembers
88
+ ]
89
+ ],
90
+ partials: [
91
+ Partials.GuildMember
92
+ ]
93
+ },
94
+ guildMemberUpdate: {
95
+ intents: [
96
+ [
97
+ GatewayIntentBits.GuildMembers
98
+ ]
99
+ ],
100
+ partials: [
101
+ Partials.GuildMember
102
+ ]
103
+ },
69
104
  guildAuditLogEntryCreate: {
70
105
  intents: [
71
106
  [
@@ -198,9 +233,7 @@ const REACTION_PARTIALS = [
198
233
  intents: [
199
234
  REACTIONS
200
235
  ],
201
- partials: [
202
- Partials.Reaction
203
- ]
236
+ partials: REACTION_PARTIALS
204
237
  },
205
238
  typingStart: {
206
239
  intents: [
@@ -212,26 +245,20 @@ const REACTION_PARTIALS = [
212
245
  },
213
246
  messagePollVoteAdd: {
214
247
  intents: [
215
- [
216
- GatewayIntentBits.GuildMessagePolls,
217
- GatewayIntentBits.DirectMessagePolls
218
- ]
219
- ]
248
+ POLLS
249
+ ],
250
+ partials: POLL_PARTIALS
220
251
  },
221
252
  messagePollVoteRemove: {
222
253
  intents: [
223
- [
224
- GatewayIntentBits.GuildMessagePolls,
225
- GatewayIntentBits.DirectMessagePolls
226
- ]
227
- ]
254
+ POLLS
255
+ ],
256
+ partials: POLL_PARTIALS
228
257
  },
229
258
  ...Object.fromEntries([
230
259
  'guildScheduledEventCreate',
231
260
  'guildScheduledEventDelete',
232
- 'guildScheduledEventUpdate',
233
- 'guildScheduledEventUserAdd',
234
- 'guildScheduledEventUserRemove'
261
+ 'guildScheduledEventUpdate'
235
262
  ].map((event)=>[
236
263
  event,
237
264
  {
@@ -242,6 +269,27 @@ const REACTION_PARTIALS = [
242
269
  ]
243
270
  }
244
271
  ])),
272
+ // Each names its user by id alone, which discord.js reads from the cache unless this partial is on
273
+ guildScheduledEventUserAdd: {
274
+ intents: [
275
+ [
276
+ GatewayIntentBits.GuildScheduledEvents
277
+ ]
278
+ ],
279
+ partials: [
280
+ Partials.User
281
+ ]
282
+ },
283
+ guildScheduledEventUserRemove: {
284
+ intents: [
285
+ [
286
+ GatewayIntentBits.GuildScheduledEvents
287
+ ]
288
+ ],
289
+ partials: [
290
+ Partials.User
291
+ ]
292
+ },
245
293
  autoModerationRuleCreate: {
246
294
  intents: [
247
295
  [
@@ -355,7 +403,7 @@ function intentName(bit) {
355
403
  (labels.some((label)=>label.startsWith("@MessageHandler('")) ? "; messages: { mention: 'only' } needs none." : '.') : ''));
356
404
  }
357
405
  for (const [partial, labels] of missingPartials){
358
- warnings.push(`Partials.${Partials[partial]} is not in clientOptions.partials, so ${labels.join(', ')} will miss ` + (partial === Partials.Channel ? 'direct messages: no DM channel is cached after the bot starts, and discord.js drops a message from a ' + 'channel it has not cached.' : 'events about messages, reactions or users the bot has not cached, such as messages sent before it started.'));
406
+ warnings.push(`Partials.${Partials[partial]} is not in clientOptions.partials, so ${labels.join(', ')} will miss ` + (partial === Partials.Channel ? 'direct messages: no DM channel is cached after the bot starts, and discord.js drops a message from a ' + 'channel it has not cached.' : CACHED_ELSEWHERE.has(partial) ? 'events about members or polls the bot has not cached, such as members who joined before it started.' : 'events about messages, reactions or users the bot has not cached, such as messages sent before it started.'));
359
407
  }
360
408
  return warnings;
361
409
  }
@@ -0,0 +1,100 @@
1
+ import { getEventHandlers } from '../decorator/event.decorator.js';
2
+ import { prepareEventHandler } from './handler-pipeline.js';
3
+ import { isAppClassToken } from './lifecycle-order.js';
4
+ import { isClassProvider } from './providers.js';
5
+
6
+ /**
7
+ * One source for each instance whose lifecycle hooks run, the cooldown store aside, in lifecycle order; read once the
8
+ * providers are made, as a value's or a factory's class is its instance's. A class `prepared` lacks has its handlers
9
+ * prepared here, and one a controller's would be refused for is left out, with `warn` naming the refusal.
10
+ */ function eventSources(container, providers, units, prepared, warn) {
11
+ // The tokens of each class the container makes, bound as itself or given as a provider's `useClass`, the cooldown
12
+ // store's included: no event reaches it, nor its instance through a factory
13
+ const classTokens = new Map();
14
+ for (const { token } of units){
15
+ const provider = providers.get(token);
16
+ if (provider && !isClassProvider(provider) || !provider && !isAppClassToken(token)) continue;
17
+ const cls = provider ? provider.useClass : token;
18
+ classTokens.set(cls, [
19
+ ...classTokens.get(cls) ?? [],
20
+ token
21
+ ]);
22
+ }
23
+ const names = new Map(prepared.map((cls)=>[
24
+ cls.name,
25
+ cls
26
+ ]));
27
+ const handlers = new Map();
28
+ const handlersOf = (cls)=>{
29
+ let found = handlers.get(cls);
30
+ if (!found) {
31
+ found = prepared.includes(cls) ? getEventHandlers(cls.prototype) : prepareClass(container, cls, names, warn);
32
+ handlers.set(cls, found);
33
+ }
34
+ return found;
35
+ };
36
+ const sources = [];
37
+ const given = new Set();
38
+ for (const { token, cooldownStore } of units){
39
+ // The cooldown store is the app's infrastructure, never one of its classes, so no event reaches it
40
+ if (cooldownStore) continue;
41
+ const provider = providers.get(token);
42
+ if (!provider || isClassProvider(provider)) {
43
+ const cls = provider ? provider.useClass : token;
44
+ const found = handlersOf(cls);
45
+ if (found.length > 0) sources.push({
46
+ cls,
47
+ handlers: found,
48
+ resolve: ()=>container.get(token)
49
+ });
50
+ continue;
51
+ }
52
+ // A value, or a factory's, made by now; one instance under several tokens is one source
53
+ const instance = container.get(token);
54
+ if (typeof instance !== 'object' || instance === null || given.has(instance)) continue;
55
+ given.add(instance);
56
+ const cls = instance.constructor;
57
+ if (typeof cls !== 'function') continue;
58
+ const found = handlersOf(cls);
59
+ if (found.length === 0) continue;
60
+ // A factory may return the instance the container made for a class unit, whose own source runs it; only units of
61
+ // the instance's class are resolved, and each resolves on the event through its own source anyway
62
+ const tokens = classTokens.get(cls) ?? [];
63
+ const owned = ()=>tokens.some((token)=>container.get(token) === instance);
64
+ sources.push({
65
+ cls,
66
+ handlers: found,
67
+ resolve: ()=>owned() ? undefined : instance
68
+ });
69
+ }
70
+ return sources;
71
+ }
72
+ /** The handlers of `cls` that pass a controller's checks, warning of each other one and why it does not run. */ function prepareClass(container, cls, names, warn) {
73
+ const declared = getEventHandlers(cls.prototype);
74
+ if (declared.length === 0) return [];
75
+ const other = names.get(cls.name);
76
+ if (!other) names.set(cls.name, cls);
77
+ // @Once tells classes apart by name, so this class's would share another's
78
+ const sharesName = other !== undefined && other !== cls && declared.some((handler)=>handler.once);
79
+ if (sharesName) {
80
+ refused(warn, cls, `${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.`, declared.filter((handler)=>handler.once));
81
+ }
82
+ const errors = new Map();
83
+ return declared.filter((handler)=>{
84
+ if (sharesName && handler.once) return false;
85
+ let found = errors.get(handler.method);
86
+ if (!found) {
87
+ found = prepareEventHandler(container, cls, handler.method);
88
+ errors.set(handler.method, found);
89
+ const onMethod = declared.filter((other)=>other.method === handler.method && !(sharesName && other.once));
90
+ for (const error of found)refused(warn, cls, error.message, onMethod);
91
+ }
92
+ return found.length === 0;
93
+ });
94
+ }
95
+ function refused(warn, cls, reason, handlers) {
96
+ const labels = handlers.map(({ event, method, once })=>`@${once ? 'Once' : 'On'}('${event}') in ${cls.name}.${method}`);
97
+ warn(`${reason} Until the next major version (5.0) refuses it at startup, ${labels.join(', ')} does not run.`);
98
+ }
99
+
100
+ export { eventSources };
@@ -9,6 +9,7 @@ import { interactionLocale, translatorOfClient, messageLocale, renderText } from
9
9
  import { logFailedSend } from '../common/response/send-failure.js';
10
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
+ import { timerKeepsSeconds } from '../util/timer-limit.util.js';
12
13
 
13
14
  const UNKNOWN_INTERACTION = 10062;
14
15
  /** Discord's refusal of a direct message: the member's are closed, or they blocked the bot. */ const CANNOT_MESSAGE_USER = 50007;
@@ -280,7 +281,8 @@ async function replyToMessage(message, error, logger, withEmoji, answering) {
280
281
  parse: []
281
282
  }
282
283
  }));
283
- if (seconds > 0) {
284
+ // Longer than a timer can wait keeps it, as 0 does; the timer would fire at once
285
+ if (seconds > 0 && timerKeepsSeconds(seconds)) {
284
286
  setTimeout(()=>{
285
287
  reply.delete().catch((failure)=>logFailedSend(logger, 'delete a reply to a command', failure));
286
288
  }, seconds * 1000).unref?.();
@@ -1,8 +1,8 @@
1
1
  import 'reflect-metadata';
2
- import { perHandler, sourcePrototype, stageClasses } from './guard-runner.js';
2
+ import { perHandler, sourcePrototype, stageClasses, stageOwners } 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) {
@@ -26,13 +26,13 @@ import { META } from '../util/metadata-keys.js';
26
26
  ];
27
27
  }
28
28
  /** A handler's method and class filters, the classes innermost first, resolved once. */ const ownFilterLevels = perHandler((prototype, methodName)=>{
29
- const source = sourcePrototype(prototype, methodName);
30
- if (!source) return {
29
+ if (!sourcePrototype(prototype, methodName)) return {
31
30
  method: [],
32
31
  classes: []
33
32
  };
34
33
  return {
35
- method: Reflect.getOwnMetadata(META.methodFilters, source, methodName) ?? [],
34
+ // An override's filters before those of the methods it overrides, innermost first as the classes are
35
+ method: stageOwners(prototype, methodName).reverse().flatMap((owner)=>Reflect.getOwnMetadata(META.methodFilters, owner, methodName) ?? []),
36
36
  classes: [
37
37
  ...stageClasses(prototype, methodName)
38
38
  ].reverse().flatMap((cls)=>Reflect.getOwnMetadata(META.classFilters, cls) ?? [])
@@ -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;