meocord 4.2.2 → 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 (136) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/dist/cjs/_shared/{controller.decorator-U2NBWcoU.cjs → controller.decorator-hp5xdJU4.cjs} +512 -183
  3. package/dist/cjs/_shared/{startup-roots-lA5xkBFq.cjs → event-sources-DzqTODXg.cjs} +265 -80
  4. package/dist/cjs/_shared/{providers-B4MOFBrW.cjs → providers-BBvbnmLP.cjs} +780 -281
  5. package/dist/cjs/_shared/{sharded-cooldown-store-HTUb3Ii_.cjs → sharded-cooldown-store-B62g5O88.cjs} +11 -9
  6. package/dist/cjs/_shared/{translate-error-CbBHUu5P.cjs → translate-error-wsgfsUET.cjs} +1 -1
  7. package/dist/cjs/common/index.cjs +4 -104
  8. package/dist/cjs/core/index.cjs +432 -181
  9. package/dist/cjs/decorator/index.cjs +85 -120
  10. package/dist/cjs/testing/index.cjs +1664 -784
  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 +27 -5
  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 +17 -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 +83 -58
  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 +4 -4
  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 +44 -28
  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 +80 -53
  60. package/dist/esm/core/meocord.app.js +174 -89
  61. package/dist/esm/core/message-help.js +5 -9
  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 +2 -1
  66. package/dist/esm/core/process-state.js +19 -0
  67. package/dist/esm/core/providers.js +28 -3
  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/startup-roots.js +28 -8
  73. package/dist/esm/core/theme-resolvers.js +26 -11
  74. package/dist/esm/core/theme-runtime.js +23 -13
  75. package/dist/esm/core/theme-scope.js +27 -29
  76. package/dist/esm/core/theme-validation.js +1 -1
  77. package/dist/esm/decorator/app.decorator.js +6 -1
  78. package/dist/esm/decorator/command-builder.decorator.js +14 -0
  79. package/dist/esm/decorator/controller-class.decorator.js +1 -1
  80. package/dist/esm/decorator/controller.decorator.js +48 -19
  81. package/dist/esm/decorator/defer.decorator.js +2 -2
  82. package/dist/esm/decorator/event.decorator.js +4 -5
  83. package/dist/esm/decorator/filter.decorator.js +2 -2
  84. package/dist/esm/decorator/guard.decorator.js +4 -61
  85. package/dist/esm/decorator/inject.decorator.js +4 -1
  86. package/dist/esm/decorator/interceptor.decorator.js +2 -2
  87. package/dist/esm/decorator/theme.decorator.js +4 -3
  88. package/dist/esm/decorator/validation.decorator.js +5 -5
  89. package/dist/esm/package.json.js +1 -1
  90. package/dist/esm/testing/catalog.js +29 -13
  91. package/dist/esm/testing/cooldown-store-suite.js +3 -3
  92. package/dist/esm/testing/discord-defaults.js +449 -355
  93. package/dist/esm/testing/inspect-handler.js +91 -10
  94. package/dist/esm/testing/meocord-testing-module.js +195 -77
  95. package/dist/esm/testing/mock-fn.js +8 -2
  96. package/dist/esm/testing/mock-interaction.js +639 -113
  97. package/dist/esm/testing/response-log.js +8 -0
  98. package/dist/esm/testing/response.js +7 -5
  99. package/dist/esm/testing/routing.js +48 -6
  100. package/dist/esm/testing/strict-mocks.js +1 -0
  101. package/dist/esm/testing/theme.js +3 -2
  102. package/dist/esm/util/common.util.js +30 -9
  103. package/dist/esm/util/generator-cli.util.js +6 -4
  104. package/dist/esm/util/inherited-env.util.js +72 -13
  105. package/dist/esm/util/json.util.js +10 -3
  106. package/dist/esm/util/meocord-cli.util.js +3 -2
  107. package/dist/esm/util/meocord-config-loader.util.js +14 -1
  108. package/dist/esm/util/meocord-config-validation.util.js +13 -3
  109. package/dist/esm/util/meocord-source-config.util.js +13 -3
  110. package/dist/esm/util/platform.util.js +3 -2
  111. package/dist/esm/util/refusal.util.js +29 -11
  112. package/dist/esm/util/registration-mode.util.js +14 -1
  113. package/dist/esm/util/timer-limit.util.js +4 -1
  114. package/dist/esm/util/tsconfig-paths.util.js +38 -7
  115. package/dist/esm/util/tsconfig.util.js +16 -58
  116. package/dist/esm/util/user-text.util.js +8 -4
  117. package/dist/esm/util/value.util.js +15 -4
  118. package/dist/types/common/index.d.cts +13 -9
  119. package/dist/types/common/index.d.ts +13 -9
  120. package/dist/types/core/index.d.cts +21 -11
  121. package/dist/types/core/index.d.ts +21 -11
  122. package/dist/types/decorator/index.d.cts +63 -63
  123. package/dist/types/decorator/index.d.ts +63 -63
  124. package/dist/types/{index-DKJclIi-.d.ts → index-Brkd6QGL.d.ts} +81 -35
  125. package/dist/types/{index-Dlf0ZZtS.d.cts → index-CqXImwF_.d.cts} +81 -35
  126. package/dist/types/interface/index.d.cts +1 -1
  127. package/dist/types/interface/index.d.ts +1 -1
  128. package/dist/types/{route-Bh1U13yy.d.ts → route-IQrK-MN2.d.cts} +11 -3
  129. package/dist/types/{route-Bh1U13yy.d.cts → route-bI-okq7g.d.ts} +11 -3
  130. package/dist/types/testing/index.d.cts +137 -54
  131. package/dist/types/testing/index.d.ts +137 -54
  132. package/dist/types/{theme-resolvers-CyEz9pnv.d.cts → theme-resolvers-AtgeaYqF.d.cts} +1 -1
  133. package/dist/types/{theme-resolvers-DA204O-q.d.ts → theme-resolvers-BgqysV8d.d.ts} +1 -1
  134. package/dist/types/{translator-Bg7hwxDF.d.cts → translator-D8aydlCE.d.ts} +12 -11
  135. package/dist/types/{translator-CeP4bCzp.d.ts → translator-iJhDlgwL.d.cts} +12 -11
  136. 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
@@ -270,7 +279,7 @@ import { existingResponse } from '../common/response/response-state.js';
270
279
  * usage through that handler's filters. A bare parent's unguarded subcommands' usage, once the app's guards allow
271
280
  * it, and a failure to read the prefixes, go through the global filters, then the fallback; the listeners still run.
272
281
  */ async message(message, record) {
273
- if (message.author.bot || !message.content?.trim()) return;
282
+ if (message.author.bot || !hasMessageText(message.content)) return;
274
283
  const call = this.callFor(record);
275
284
  let target;
276
285
  try {
@@ -297,24 +306,29 @@ import { existingResponse } from '../common/response/response-state.js';
297
306
  }
298
307
  // The matched handler, then every listener, each resolved from the container only as it starts
299
308
  const runs = [];
309
+ const unresolved = new Set();
300
310
  if (target) {
301
311
  const { route, params, start, given } = target;
302
312
  noteInvocation(message, `${start}${commandWordsOf(route.tokens).join(' ')}`);
303
313
  const hooks = messageCommandHooks(route, params, message, start, given, this.messageOptions.types);
304
314
  runs.push({
305
315
  name: `${route.controllerClass.name}.${route.method}`,
306
- run: (at)=>this.invokeHandler(this.getInstance(route.controllerClass), route.method, [
307
- message,
308
- params
309
- ], 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)
310
322
  });
311
323
  }
312
324
  for (const { controllerClass, method } of this.messageListeners){
313
325
  runs.push({
314
326
  name: `${controllerClass.name}.${method}`,
315
- run: (at)=>this.invokeHandler(this.getInstance(controllerClass), method, [
327
+ run: (at)=>this.withInstance(controllerClass, [
316
328
  message
317
- ], at)
329
+ ], at, (instance)=>this.invokeHandler(instance, method, [
330
+ message
331
+ ], at), unresolved)
318
332
  });
319
333
  }
320
334
  if (this.messageOptions.handlers === 'concurrent') await this.runTogether(runs, call);
@@ -339,7 +353,8 @@ import { existingResponse } from '../common/response/response-state.js';
339
353
  ...call,
340
354
  record: {
341
355
  settled: (...report)=>reports[index].push(report),
342
- unhandled: (error)=>record.unhandled(error)
356
+ unhandled: (error)=>record.unhandled(error),
357
+ unanswered: (error, failure)=>record.unanswered(error, failure)
343
358
  }
344
359
  } : call)));
345
360
  for (const report of reports.flat())record?.settled(...report);
@@ -419,7 +434,7 @@ import { existingResponse } from '../common/response/response-state.js';
419
434
  const matching = this.controllerClasses.map((controllerClass)=>({
420
435
  controllerClass,
421
436
  // Handlers for this emoji first, then those for every emoji
422
- 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))
423
438
  })).filter(({ handlers })=>handlers.length > 0);
424
439
  if (matching.length === 0) return;
425
440
  // Only asked when a matching handler leaves bots out, since a partial user costs a fetch
@@ -443,16 +458,21 @@ import { existingResponse } from '../common/response/response-state.js';
443
458
  return;
444
459
  }
445
460
  for (const { controllerClass, handlers } of runs){
446
- const controllerInstance = this.getInstance(controllerClass);
447
- for (const { method } of handlers){
448
- 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, [
449
469
  reaction,
450
470
  {
451
471
  user,
452
472
  action
453
473
  }
454
474
  ], call);
455
- }
475
+ });
456
476
  }
457
477
  }
458
478
  /**
@@ -471,7 +491,10 @@ import { existingResponse } from '../common/response/response-state.js';
471
491
  constructor(options){
472
492
  this.options = options;
473
493
  this.controllerInstancesCache = new Map();
474
- /** 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();
475
498
  /** The handlers already warned about, so each is named once however often it runs. */ this.warnedUnanswered = new Set();
476
499
  /** The message handlers already warned about as slow, so each is named once. */ this.warnedSlow = new Set();
477
500
  this.container = options.container;
@@ -486,6 +509,8 @@ import { existingResponse } from '../common/response/response-state.js';
486
509
  // The app's own help command reads this table too, so it lists the commands dispatch reaches
487
510
  if (this.container.isBound(HandlerRegistry)) shareMessageRoutes(this.container.get(HandlerRegistry), this.messageRoutes);
488
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);
489
514
  this.messageListeners = this.controllerClasses.flatMap((controllerClass)=>getMessageHandlers(controllerClass.prototype).filter((handler)=>handler.pattern === undefined).map(({ method })=>({
490
515
  controllerClass,
491
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,5 +1,5 @@
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
5
  import { isConstructible } from '../util/value.util.js';
@@ -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) ?? [])