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,12 +1,13 @@
1
1
  import { REST, Routes, Message } from 'discord.js';
2
2
  import { Logger, hideInLogs } from '../common/logger.js';
3
- import { warnDeprecatedBehaviour } from '../common/deprecation.js';
4
- import './theme-scope.js';
5
- import './theme-validation.js';
3
+ import '../common/theme.js';
6
4
  import '../common/decorator.js';
7
5
  import 'reflect-metadata';
6
+ import { warnDeprecatedBehaviour } from '../common/deprecation.js';
8
7
  import '../common/response/response-state.js';
8
+ import './theme-scope.js';
9
9
  import { GuardDeniedError, UserError } from '../common/errors.js';
10
+ import '../common/cooldown-store.js';
10
11
  import 'node:crypto';
11
12
  import { getMessageHandlers, getReactionHandlers } from '../decorator/controller.decorator.js';
12
13
  import './scalar-types.js';
@@ -15,18 +16,17 @@ import { managerGone } from '../util/sharding-mode.util.js';
15
16
  import '../common/translator.js';
16
17
  import '../common/meocord-text.js';
17
18
  import { markExplained } from '../common/explained-error.js';
19
+ import './theme-resolvers.js';
18
20
  import { messageDecorator, reactionDecorator } from './command-conflicts.js';
19
21
  import { ReactionHandlerAction } from '../enum/controller.enum.js';
20
22
  import { globalStagesOf, runHandler } from './handler-pipeline.js';
21
23
  import { createFallback, replyWithUserError } from './fallback.js';
22
24
  import { waitForCooldownStore, claimStoreDownNotice, claimCooldownNotice } from './cooldown-runner.js';
23
25
  import { stageTypes, stageClass } from './stage-scope.js';
24
- import { getEventHandlers } from '../decorator/event.decorator.js';
25
26
  import { eventRequirements, messageHandlerRequirements, REACTION_HANDLER_REQUIREMENTS, missingRequirementWarnings } from './event-requirements.js';
26
- import { classUnits } from './lifecycle-order.js';
27
- import { runReadyHooks, runShutdownSequence, ReadyPass } from './lifecycle-hooks.js';
27
+ import { runShutdownSequence, runReadyHooks, lifecycleEntry, ReadyPass } from './lifecycle-hooks.js';
28
28
  import { RunningCalls } from './running-calls.js';
29
- import { stopRequests } from '../util/stop-request.util.js';
29
+ import { processState } from './process-state.js';
30
30
  import { tellDevRunner, onDevRunnerStop } from '../util/dev-runner.util.js';
31
31
  import { fatalLoginCode, explainLoginFailure, tokenMessage, isRefusedToken } from './login-failure.js';
32
32
  import { endFailedShard, tellManager } from './shard-exit.js';
@@ -36,21 +36,18 @@ import { undoFailedLogin } from './failed-login.js';
36
36
  import { ownInteractionListener, Dispatcher } from './dispatcher.js';
37
37
  import { deliverUncachedDmReactions } from './dm-reactions.js';
38
38
  import { loadMeoCordConfig } from '../util/meocord-config-loader.util.js';
39
- import { isRegisterOnly, FORCE_REGISTER_ENV, REGISTER_GUILD_ENV } from '../util/registration-mode.util.js';
39
+ import { isRegisterOnly, FORCE_REGISTER_ENV, registersAsDevelopment, REGISTER_GUILD_ENV } from '../util/registration-mode.util.js';
40
40
  import { shutdownTimeoutOf } from '../util/shutdown-timeout.util.js';
41
41
  import { escapeForLog } from '../util/user-text.util.js';
42
42
 
43
43
  /** How long an `onReady` hook runs before a warning says the hooks after it are waiting. */ const SLOW_READY_HOOK_MS = 10000;
44
- /** Closes each started app: runs its shutdown hooks and destroys its client, resolving `false` on failure. */ const runningApps = new Set();
45
- const stopRequest = stopRequests();
46
- let signalHandlersInstalled = false;
47
- let shuttingDown;
44
+ const { runningApps } = processState;
48
45
  /**
49
- * Shuts every started app down and exits, with a code already set or 0, and 1 if a client failed to close. Signals call
50
- * it, and in a shard its manager's request or its going. A call within `REPEAT_SIGNAL_WINDOW_MS` of the first is the
46
+ * Shuts every started app down and exits, with the code set when the shutdown began or 0, and 1 if an app failed to
47
+ * close. Signals call it, and in a shard its manager's request or its going. A call within `REPEAT_SIGNAL_WINDOW_MS` of the first is the
51
48
  * same request; one after it forces exit 1, except in a shard, whose manager forces it.
52
49
  */ async function shutdownAndExit(shard) {
53
- const request = stopRequest();
50
+ const request = processState.stopRequest();
54
51
  if (request !== 'first') {
55
52
  // A shard hears Ctrl+C both directly and from its manager, which owns forcing it; so it waits
56
53
  if (request === 'duplicate' || shard) return;
@@ -60,23 +57,28 @@ let shuttingDown;
60
57
  return shutDown();
61
58
  }
62
59
  /** The one shutdown of the process, however many ask for it: a stop request joins one already under way. */ function shutDown() {
63
- shuttingDown ?? (shuttingDown = (async ()=>{
60
+ var _processState;
61
+ (_processState = processState).shuttingDown ?? (_processState.shuttingDown = (async ()=>{
62
+ // A clean stop keeps a code already set, such as the 1 a failed login set, so a supervisor still sees it. Read as
63
+ // the shutdown begins: a code main.ts sets as it hears of the stop is the stop's, not a failure.
64
+ const exitCode = Number(process.exitCode ?? 0) || 0;
64
65
  const closed = await Promise.all([
65
66
  ...runningApps
66
67
  ].map((close)=>close()));
67
- // A clean stop keeps a code already set, such as the 1 a failed login set, so a supervisor still sees it
68
- process.exit(closed.every(Boolean) ? Number(process.exitCode ?? 0) || 0 : 1);
68
+ process.exit(closed.every(Boolean) ? exitCode : 1);
69
69
  })());
70
- return shuttingDown;
70
+ return processState.shuttingDown;
71
71
  }
72
+ /** The errors {@link stoppedBeforeOnline} made, which end a start without being its failure. */ const stops = new WeakSet();
72
73
  /** What `start()` rejects with when shutdown begins before the bot came online, which shutdown has logged. */ function stoppedBeforeOnline() {
73
74
  const error = new Error('The bot was stopped before it came online.');
74
75
  markExplained(error);
76
+ stops.add(error);
75
77
  return error;
76
78
  }
77
79
  /** One pair of signal listeners for the process, however many apps it starts, for the role the first decided. */ function installSignalHandlers(shard) {
78
- if (signalHandlersInstalled) return;
79
- signalHandlersInstalled = true;
80
+ if (processState.signalHandlersInstalled) return;
81
+ processState.signalHandlersInstalled = true;
80
82
  process.on('SIGINT', ()=>void shutdownAndExit(shard));
81
83
  process.on('SIGTERM', ()=>void shutdownAndExit(shard));
82
84
  // How `meocord start --dev` stops the application to restart it, a signal being no graceful stop on Windows. It is
@@ -129,13 +131,21 @@ class MeoCordApp {
129
131
  }
130
132
  }
131
133
  /**
132
- * Logs the bot in, as {@link MeoCordApplication.start} describes. The providers, services and event handlers are set
133
- * up once, or again after a provider's factory failed; a retry after a failed login reuses them.
134
+ * Logs the bot in, as {@link MeoCordApplication.start} describes. The event handlers are attached once; the providers
135
+ * and services are made for each start, as a failed one, of a factory or the login, shuts them down and drops them.
134
136
  */ async start() {
135
137
  if (isRegisterOnly()) return this.registerOnly();
136
138
  if (this.stopped) throw new Error('This app was stopped; use MeoCordFactory.create to make a new one.');
137
139
  if (this.online) return;
138
- this.starting ?? (this.starting = this.startOnce().catch((error)=>{
140
+ this.starting ?? (this.starting = this.startOnce().catch(async (error)=>{
141
+ // A failed start stays among the running apps while it shuts down what it made, which a stop meanwhile waits
142
+ // for, and then while it holds a value it was given open, which a signal closes as stop() does. One that holds
143
+ // nothing leaves, so a signal later stops only the apps that run; a stopped start stays until its close settles
144
+ if (!this.closing) {
145
+ this.undoing = this.undoStart().finally(()=>this.undoing = undefined);
146
+ await this.undoing;
147
+ if (!this.closing && !this.holdsOpen()) runningApps.delete(this.close);
148
+ }
139
149
  // A shard ends, for its manager to restart, or to stop all if told it is fatal; a stopped start is the stop's
140
150
  if (this.shard && !this.closing) endFailedShard(error);
141
151
  throw error;
@@ -156,6 +166,30 @@ class MeoCordApp {
156
166
  // As a signal's shutdown exits 1 for a client that failed to close, a code another failure set aside
157
167
  if (!await this.close() && !process.exitCode) process.exitCode = 1;
158
168
  }
169
+ /**
170
+ * Shuts down what a failed start made, by the rule a stop follows, and drops it from the container, so the process
171
+ * holds nothing of it open and a retry makes it afresh.
172
+ */ async undoStart() {
173
+ if (!this.attempt || !this.begun) return;
174
+ this.begun = false;
175
+ const { given, constructed } = this.attempt;
176
+ const entries = this.shutdownEntries().filter(({ token })=>!given.has(token));
177
+ // A value the app gave stays constructed, so a stop with no retry still closes it
178
+ for (const token of constructed)if (!given.has(token)) constructed.delete(token);
179
+ this.readySettled.clear();
180
+ this.attempt.drop();
181
+ this.made = false;
182
+ // Nothing came online, so no call runs and no listener needs removing
183
+ await runShutdownSequence(this.container, ()=>entries, {
184
+ drainCalls: ()=>this.calls.settled(),
185
+ timeoutMs: this.shutdownTimeout,
186
+ hookFailed: (name, error)=>this.logger.error(`onShutdown failed in ${name}:`, error),
187
+ warn: (message)=>this.logger.warn(message)
188
+ });
189
+ }
190
+ /** Whether a shutdown has an `onShutdown` to run, as for a value the app gave once a failed start is undone. */ holdsOpen() {
191
+ return this.shutdownEntries().some(({ instance })=>typeof instance.onShutdown === 'function');
192
+ }
159
193
  async startOnce() {
160
194
  this.logger.log('Starting bot...');
161
195
  // Before the providers are made, so a signal, or a shard's manager, can stop a start that is slow to come online
@@ -166,7 +200,9 @@ class MeoCordApp {
166
200
  // A bot runs one app, whose theme code outside any call reads, its providers' factories included; claimed for
167
201
  // each start, as a failed one gives it up
168
202
  claimAmbientAppTheme(this.container);
169
- if (!this.prepared) {
203
+ if (!this.made) {
204
+ this.attempt?.begin();
205
+ this.begun = true;
170
206
  // Kept while it runs, so a stop meanwhile waits for it
171
207
  this.preparing = this.prepare();
172
208
  try {
@@ -174,7 +210,11 @@ class MeoCordApp {
174
210
  } finally{
175
211
  this.preparing = undefined;
176
212
  }
177
- this.prepared = true;
213
+ this.made = true;
214
+ }
215
+ if (!this.attached) {
216
+ this.attachHandlers();
217
+ this.attached = true;
178
218
  }
179
219
  if (this.loginFailed) {
180
220
  warnDeprecatedBehaviour(this.logger, 'Retrying start() after a failed login', 'rejects', 'MeoCordFactory.create to make a new app');
@@ -182,7 +222,7 @@ class MeoCordApp {
182
222
  }
183
223
  await this.login();
184
224
  }
185
- /** Makes the provided values and listed services, and attaches the Discord event handlers. */ async prepare() {
225
+ /** Makes the provided values and listed services. */ async prepare() {
186
226
  // Every provided value is made before anything that injects it is resolved, and before login
187
227
  if (this.startup) {
188
228
  try {
@@ -194,7 +234,7 @@ class MeoCordApp {
194
234
  releaseAmbientAppTheme(this.container);
195
235
  if (process.exitCode === undefined || process.exitCode === 0) {
196
236
  process.exitCode = 1;
197
- MeoCordApp.failedLoginSetExitCode = true;
237
+ processState.startFailureSetExitCode = true;
198
238
  }
199
239
  throw error;
200
240
  }
@@ -207,6 +247,8 @@ class MeoCordApp {
207
247
  this.storeReady = Promise.withResolvers();
208
248
  waitForCooldownStore(this.container, this.storeReady.promise);
209
249
  }
250
+ }
251
+ /** Attaches the Discord event handlers. */ attachHandlers() {
210
252
  this.bot.on('clientReady', (readyClient)=>this.runListener('clientReady', async ()=>{
211
253
  // Only for activities the app listed: without them, a status it sets itself stays as it set it
212
254
  if (this.activities?.length) {
@@ -231,9 +273,10 @@ class MeoCordApp {
231
273
  })));
232
274
  // discord.js drops a reaction in a DM channel it has not cached; this fetches the channel and delivers it
233
275
  deliverUncachedDmReactions(this.bot, this.logger);
234
- this.attachEventHandlers();
235
- this.warnAboutMissingRequirements();
236
- this.noteGlobalStagesOnEvents();
276
+ const sources = this.eventSources();
277
+ this.attachEventHandlers(sources);
278
+ this.warnAboutMissingRequirements(sources);
279
+ this.noteGlobalStagesOnEvents(sources);
237
280
  }
238
281
  /** Logs the bot in, setting the exit code when that fails and clearing it when a later attempt succeeds. */ async login() {
239
282
  // Checked here as well, after the last await before the login: from here to setting abortLogin runs in one turn,
@@ -254,7 +297,6 @@ class MeoCordApp {
254
297
  ]);
255
298
  } catch (error) {
256
299
  if (this.closing) throw error;
257
- runningApps.delete(this.close);
258
300
  // A bot that never came online is not the app whose theme is read outside calls
259
301
  releaseAmbientAppTheme(this.container);
260
302
  const fatal = fatalLoginCode(error);
@@ -269,12 +311,12 @@ class MeoCordApp {
269
311
  if (fatal) await reportFatalLogin(this.shard, fatal, explanation ?? (error instanceof Error ? error.message : String(error)));
270
312
  if (process.exitCode === undefined || process.exitCode === 0) {
271
313
  process.exitCode = 1;
272
- MeoCordApp.failedLoginSetExitCode = true;
314
+ processState.startFailureSetExitCode = true;
273
315
  }
274
316
  await tellDevRunner({
275
317
  meocord: 'login-failed'
276
318
  }, this.shard);
277
- MeoCordApp.toldDevRunnerLoginFailed = true;
319
+ processState.toldDevRunnerLoginFailed = true;
278
320
  this.loginFailed = true;
279
321
  throw error;
280
322
  } finally{
@@ -283,12 +325,12 @@ class MeoCordApp {
283
325
  // A shutdown that began as the login completed has the client to close; the bot is not reported online
284
326
  if (this.closing) throw stoppedBeforeOnline();
285
327
  // 0 rather than undefined, which Bun ignores
286
- if (MeoCordApp.failedLoginSetExitCode && process.exitCode === 1) {
328
+ if (processState.startFailureSetExitCode && process.exitCode === 1) {
287
329
  process.exitCode = 0;
288
- MeoCordApp.failedLoginSetExitCode = false;
330
+ processState.startFailureSetExitCode = false;
289
331
  }
290
- if (MeoCordApp.toldDevRunnerLoginFailed) {
291
- MeoCordApp.toldDevRunnerLoginFailed = false;
332
+ if (processState.toldDevRunnerLoginFailed) {
333
+ processState.toldDevRunnerLoginFailed = false;
292
334
  await tellDevRunner({
293
335
  meocord: 'online'
294
336
  }, this.shard);
@@ -298,11 +340,16 @@ class MeoCordApp {
298
340
  }
299
341
  /**
300
342
  * Registers the application's commands with Discord, where `meocord.config.ts`'s `commands` says. Runs once the bot
301
- * is ready, except in a shard, whose manager registers. It never throws: a failure is logged and the bot stays
343
+ * is ready, except in a shard, whose manager registers. Called before the bot is online, it logs a warning and
344
+ * registers nothing, as the application comes from the login. It never throws: a failure is logged and the bot stays
302
345
  * online.
303
346
  */ async registerCommands() {
347
+ // discord.js sets the application when the gateway's READY arrives
304
348
  const applicationId = this.bot.application?.id;
305
- if (!applicationId) return;
349
+ if (!applicationId) {
350
+ this.logger.warn('registerCommands() registers nothing before the bot is online, as Discord names the application at login. ' + 'The bot registers once it is online; to register without logging in, run `meocord register`.');
351
+ return;
352
+ }
306
353
  const config = loadMeoCordConfig()?.commands;
307
354
  if (config?.register === false) {
308
355
  this.logger.log('Command registration is off (commands.register: false); run `meocord register` to register.');
@@ -314,7 +361,7 @@ class MeoCordApp {
314
361
  controllerClasses: this.controllerClasses,
315
362
  logger: this.logger,
316
363
  config,
317
- development: process.env.NODE_ENV === 'development',
364
+ development: registersAsDevelopment(),
318
365
  force: process.env[FORCE_REGISTER_ENV] === '1'
319
366
  });
320
367
  }
@@ -345,24 +392,25 @@ class MeoCordApp {
345
392
  controllerClasses: this.controllerClasses,
346
393
  logger: this.logger,
347
394
  config: loadMeoCordConfig()?.commands,
348
- development: process.env.NODE_ENV === 'development',
395
+ development: registersAsDevelopment(),
349
396
  onlyGuild: process.env[REGISTER_GUILD_ENV],
350
397
  force: true
351
398
  });
352
399
  process.exit(registered ? 0 : 1);
353
400
  }
354
401
  /**
355
- * Adds a client listener for every `@On` and `@Once` handler on the app's controllers and services, each event run on
356
- * the class's one instance from the container. Each call is isolated: an error is logged against the event and the
357
- * handler, and the next listener still runs.
358
- */ attachEventHandlers() {
359
- for (const lifecycleClass of this.lifecycleClasses){
360
- for (const { event, method, once } of getEventHandlers(lifecycleClass.prototype)){
361
- const logError = (error)=>this.logger.error(`Error handling event "${event}" in ${lifecycleClass.name}.${method}:`, error);
402
+ * Adds a client listener for every `@On` and `@Once` handler on each instance whose lifecycle hooks run, in lifecycle
403
+ * order, each event run on the instance the container holds. Each call is isolated: an error is logged against the
404
+ * event and the handler, and the next listener still runs.
405
+ */ attachEventHandlers(sources) {
406
+ for (const source of sources){
407
+ const { name } = source.cls;
408
+ for (const { event, method, once } of source.handlers){
409
+ const logError = (error)=>this.logger.error(`Error handling event "${event}" in ${name}.${method}:`, error);
362
410
  // An error no filter handles is logged, with the handler. A guard denying an event only filters which
363
411
  // events the handler takes, and a UserError is meant for the sender of the event's message, if it has one
364
412
  const fallback = async (error, context)=>{
365
- const where = `event "${event}" in ${lifecycleClass.name}.${method}`;
413
+ const where = `event "${event}" in ${name}.${method}`;
366
414
  if (error instanceof GuardDeniedError) this.logger.debug(`Denied ${where}: ${escapeForLog(error.message)}`);
367
415
  else if (error instanceof UserError) {
368
416
  this.logger.debug(`Refused ${where}: ${escapeForLog(error.message)}`);
@@ -374,12 +422,12 @@ class MeoCordApp {
374
422
  } else logError(error);
375
423
  return undefined;
376
424
  };
377
- const label = `@${once ? 'Once' : 'On'}('${event}') in ${lifecycleClass.name}.${method}`;
425
+ const label = `@${once ? 'Once' : 'On'}('${event}') in ${name}.${method}`;
378
426
  // A call, as a dispatched one is, which shutdown waits for
379
427
  const listener = (...args)=>this.calls.run(label, async ()=>{
380
428
  try {
381
- const instance = this.container.get(lifecycleClass);
382
- await runHandler(this.container, instance, method, args, {
429
+ const instance = source.resolve();
430
+ if (instance) await runHandler(this.container, instance, method, args, {
383
431
  fallback,
384
432
  type: 'event'
385
433
  });
@@ -396,8 +444,8 @@ class MeoCordApp {
396
444
  /**
397
445
  * Says, once per stage, which global guards and interceptors declare no `types` and so also run
398
446
  * before `@On` and `@Once` handlers, when the app has any.
399
- */ noteGlobalStagesOnEvents() {
400
- if (!this.lifecycleClasses.some((cls)=>getEventHandlers(cls.prototype).length > 0)) return;
447
+ */ noteGlobalStagesOnEvents(sources) {
448
+ if (sources.length === 0) return;
401
449
  const { guards, interceptors } = globalStagesOf(this.container);
402
450
  for (const [label, decorator, entries] of [
403
451
  [
@@ -420,29 +468,29 @@ class MeoCordApp {
420
468
  /**
421
469
  * Warns about handlers whose events the client options will not deliver, once per missing intent or partial. Only a
422
470
  * controller's message and reaction handlers are counted, as only those run.
423
- */ warnAboutMissingRequirements() {
471
+ */ warnAboutMissingRequirements(sources) {
424
472
  const options = this.bot.options;
425
473
  if (!options?.intents) return;
426
- const controllers = new Set(this.controllerClasses);
427
474
  const handlers = [];
428
- for (const lifecycleClass of this.lifecycleClasses){
429
- const prototype = lifecycleClass.prototype;
430
- for (const { event, method, once } of getEventHandlers(prototype)){
475
+ for (const { cls, handlers: events } of sources){
476
+ for (const { event, method, once } of events){
431
477
  handlers.push({
432
- label: `@${once ? 'Once' : 'On'}('${event}') in ${lifecycleClass.name}.${method}`,
478
+ label: `@${once ? 'Once' : 'On'}('${event}') in ${cls.name}.${method}`,
433
479
  requirements: eventRequirements(event)
434
480
  });
435
481
  }
436
- if (!controllers.has(lifecycleClass)) continue;
482
+ }
483
+ for (const controller of this.controllerClasses){
484
+ const prototype = controller.prototype;
437
485
  for (const handler of getMessageHandlers(prototype)){
438
486
  handlers.push({
439
- label: `${messageDecorator(handler.pattern)} in ${lifecycleClass.name}.${handler.method}`,
487
+ label: `${messageDecorator(handler.pattern)} in ${controller.name}.${handler.method}`,
440
488
  requirements: messageHandlerRequirements(handler, this.messageOptions ?? {})
441
489
  });
442
490
  }
443
491
  for (const { emoji, method } of getReactionHandlers(prototype)){
444
492
  handlers.push({
445
- label: `${reactionDecorator(emoji)} in ${lifecycleClass.name}.${method}`,
493
+ label: `${reactionDecorator(emoji)} in ${controller.name}.${method}`,
446
494
  requirements: REACTION_HANDLER_REQUIREMENTS
447
495
  });
448
496
  }
@@ -454,11 +502,9 @@ class MeoCordApp {
454
502
  * at a time, in dependency order. A failure is logged and the next one still runs, with a warning for each hook
455
503
  * whose dependencies failed. Once shutdown begins, no further hook starts.
456
504
  */ async runReadyHooks(client) {
457
- const entries = [];
458
- this.lifecycleEntries = entries;
459
505
  await this.readyPass.run(()=>runReadyHooks(this.container, this.lifecycleUnits, client, {
460
506
  primary: client.shard ? client.shard.ids.includes(0) : true
461
- }, entries, {
507
+ }, {
462
508
  resolveFailed: (unit, error)=>this.logger.error(`Could not resolve ${unit.name} to run its lifecycle hooks:`, error),
463
509
  hookFailed: (unit, error)=>this.logger.error(`onReady failed in ${unit.name}:`, error),
464
510
  dependsOnFailed: (unit, failed)=>this.logger.warn(`Running onReady in ${unit.name} although it depends on ${failed.map(({ name })=>name).join(', ')}, which failed.`),
@@ -467,15 +513,37 @@ class MeoCordApp {
467
513
  {
468
514
  stopped: ()=>this.closing,
469
515
  slowAfterMs: SLOW_READY_HOOK_MS,
470
- settled: (unit)=>unit.cooldownStore && this.storeReady?.resolve(),
516
+ settled: (unit)=>{
517
+ this.readySettled.add(unit.token);
518
+ if (unit.cooldownStore) this.storeReady?.resolve();
519
+ },
471
520
  runOnReady: this.readyPass.runOnReady
472
521
  }));
473
522
  // Also when shutdown stopped the hooks before the store's, so no call waits on it
474
523
  this.storeReady?.resolve();
475
524
  }
476
- /** Runs the `onShutdown` hooks through the shutdown sequence, within the configured `shutdownTimeout`. */ async runShutdownHooks() {
477
- // Read once the onReady in progress settled; a login that failed never ran onReady, so there is no onShutdown to undo
478
- await runShutdownSequence(this.container, ()=>this.lifecycleEntries ?? [], {
525
+ /**
526
+ * The units a shutdown closes: each one the container constructed whose `onReady`, and that of every unit it depends
527
+ * on, has settled or doesn't exist, in dependency order. A unit whose `onReady` never ran gets no `onShutdown`, so a
528
+ * hook that undoes `onReady` never runs without it, and neither does anything that depends on it.
529
+ */ shutdownEntries() {
530
+ const units = new Set(this.lifecycleUnits.map(({ token })=>token));
531
+ const closes = new Set();
532
+ const entries = [];
533
+ for (const unit of this.lifecycleUnits){
534
+ if (!this.attempt?.constructed.has(unit.token)) continue;
535
+ // Already made, so this returns the instance; a provided value may be anything, null included
536
+ const instance = this.container.get(unit.token) ?? {};
537
+ const ready = typeof instance.onReady !== 'function' || this.readySettled.has(unit.token);
538
+ if (!ready || unit.dependencies.some((dependency)=>units.has(dependency) && !closes.has(dependency))) continue;
539
+ closes.add(unit.token);
540
+ entries.push(lifecycleEntry(unit, instance));
541
+ }
542
+ return entries;
543
+ }
544
+ /** Runs the `onShutdown` hooks through the shutdown sequence, within `timeoutMs`: the stop's share of `shutdownTimeout`. */ async runShutdownHooks(timeoutMs) {
545
+ // Read once the onReady in progress settled
546
+ await runShutdownSequence(this.container, ()=>this.shutdownEntries(), {
479
547
  // No new call starts once the listeners are gone; an error the client emits meanwhile still has its listeners
480
548
  drainCalls: ()=>{
481
549
  const emitter = this.bot;
@@ -485,7 +553,7 @@ class MeoCordApp {
485
553
  drainAlways: true,
486
554
  runningCalls: ()=>this.calls.running(),
487
555
  readyPass: this.readyPass,
488
- timeoutMs: this.shutdownTimeout,
556
+ timeoutMs,
489
557
  hookFailed: (name, error)=>this.logger.error(`onShutdown failed in ${name}:`, error),
490
558
  warn: (message)=>this.logger.warn(message)
491
559
  });
@@ -493,7 +561,8 @@ class MeoCordApp {
493
561
  /**
494
562
  * Runs the shutdown hooks, if the ready hooks ran, then destroys the client; a stop during login ends the login.
495
563
  *
496
- * @returns Whether the client closed cleanly, which an ended login counts as.
564
+ * @returns Whether the client closed cleanly, which an ended login counts as, and the startup it waited for, if any,
565
+ * did not fail.
497
566
  */ async closeClient() {
498
567
  this.closing = true;
499
568
  try {
@@ -506,56 +575,71 @@ class MeoCordApp {
506
575
  /**
507
576
  * Waits for the startup under way to stop making providers and services, at most `shutdownTimeout`; past it, warns
508
577
  * with the factory it still awaits, which then makes nothing more.
578
+ *
579
+ * @returns Whether the startup failed meanwhile, as a provider's factory that rejects does, rather than stopping.
509
580
  */ async waitForStartup(preparing) {
510
581
  let timer;
511
582
  const outlasted = new Promise((resolve)=>{
512
- timer = setTimeout(()=>resolve(true), this.shutdownTimeout);
583
+ timer = setTimeout(()=>resolve('outlasted'), this.shutdownTimeout);
513
584
  });
514
- const settled = preparing.then(()=>false, ()=>false);
515
- if (await Promise.race([
585
+ const settled = preparing.then(()=>'stopped', (error)=>error instanceof Error && stops.has(error) ? 'stopped' : 'failed');
586
+ const outcome = await Promise.race([
516
587
  settled,
517
588
  outlasted
518
- ])) {
589
+ ]);
590
+ clearTimeout(timer);
591
+ if (outcome === 'outlasted') {
519
592
  const what = this.pendingProvider ? `the factory providing ${this.pendingProvider}` : 'its providers';
520
593
  this.logger.warn(`The startup still waits for ${what} after ${this.shutdownTimeout} ms; shutting down without it.`);
521
594
  }
522
- clearTimeout(timer);
595
+ return outcome === 'failed';
523
596
  }
524
597
  async shutDownClient() {
525
598
  this.logger.log('Shutting down bot...');
526
- // So nothing is made after the bot has shut down
527
- if (this.preparing) await this.waitForStartup(this.preparing);
528
- // Nothing came online, so there are no hooks to undo; the listeners go first, so none of them runs
599
+ // A failed start's shutdown under way finishes first, so its hooks run in full, before those of what it left open.
600
+ // `shutdownTimeout` bounds the whole stop, as a shard's manager waits only that long, so the rest gets what is left
601
+ let timeoutMs = this.shutdownTimeout;
602
+ if (this.undoing) {
603
+ const waitedFrom = Date.now();
604
+ await this.undoing;
605
+ timeoutMs = Math.max(0, timeoutMs - (Date.now() - waitedFrom));
606
+ }
607
+ // So nothing is made after the bot has shut down; a factory that fails meanwhile is a failure of the stop too
608
+ const startupFailed = this.preparing !== undefined && await this.waitForStartup(this.preparing);
609
+ // Nothing came online, so no onReady ran; the listeners go first, so none of them runs, then what the start made
610
+ // without an onReady to wait for shuts down
529
611
  if (this.abortLogin) {
530
612
  this.logger.log('The bot was still logging in, so it stops without coming online');
531
613
  this.bot.removeAllListeners();
532
614
  this.abortLogin();
615
+ await this.runShutdownHooks(timeoutMs);
533
616
  this.logger.log('Bot has shut down');
534
617
  return true;
535
618
  }
536
619
  if (this.activityInterval) clearInterval(this.activityInterval);
537
620
  // The calls drain on every shutdown, and the onReady in progress settles first
538
- await this.runShutdownHooks();
621
+ await this.runShutdownHooks(timeoutMs);
539
622
  try {
540
623
  this.bot.removeAllListeners();
541
624
  await this.bot.destroy();
542
625
  this.logger.log('Bot has shut down');
543
- return true;
626
+ return !startupFailed;
544
627
  } catch (error) {
545
628
  this.logger.error('Error during shutdown:', error);
546
629
  return false;
547
630
  }
548
631
  }
549
- constructor(controllerClasses, container, discordClient, discordToken, activities, lifecycleClasses = [], shutdownTimeout, startup, lifecycleUnits, messageOptions = {}, warnUnanswered = false, /** Whether a manager spawned this process, as `MeoCordFactory.create` decided before anything could change it. */ shard = false){
632
+ constructor(controllerClasses, container, discordClient, discordToken, activities, /** The instances whose `@On` and `@Once` handlers run, read once the providers are made. */ eventSources = ()=>[], shutdownTimeout, startup, lifecycleUnits, messageOptions = {}, warnUnanswered = false, /** Whether a manager spawned this process, as `MeoCordFactory.create` decided before anything could change it. */ shard = false, /** What each start makes; without one, as an app built by hand in a spec, nothing is shut down before ready. */ attempt){
550
633
  this.controllerClasses = controllerClasses;
551
634
  this.container = container;
552
635
  this.discordClient = discordClient;
553
636
  this.discordToken = discordToken;
554
637
  this.activities = activities;
555
- this.lifecycleClasses = lifecycleClasses;
638
+ this.eventSources = eventSources;
556
639
  this.startup = startup;
557
640
  this.messageOptions = messageOptions;
558
641
  this.shard = shard;
642
+ this.attempt = attempt;
559
643
  this.logger = new Logger(MeoCordApp.name);
560
644
  this.fallback = createFallback(this.logger, ()=>this.messageOptions, {
561
645
  cooldownNotice: (refusal)=>claimCooldownNotice(this.container, refusal),
@@ -564,18 +648,21 @@ class MeoCordApp {
564
648
  this.activityInterval = null;
565
649
  /** Where the activities' rotation is: the index of the next one shown. */ this.nextActivity = 0;
566
650
  /** Whether shutdown has begun, so the ready hooks start no more. */ this.closing = false;
651
+ /** The tokens of the units whose `onReady` the ready pass has settled, or found they have none. */ this.readySettled = new Set();
567
652
  /**
568
653
  * Closes the app once, however many stops and signals ask: whether the client was destroyed cleanly. The app stays
569
654
  * among the running ones until the close settles, so a signal or a manager's request meanwhile waits for it.
570
655
  */ this.close = ()=>this.stopped ?? (this.stopped = this.closeClient().finally(()=>runningApps.delete(this.close)));
571
656
  /** The calls under way, which shutdown waits for, less one that asked the app to stop. */ this.calls = new RunningCalls();
572
657
  /** The ready hooks' pass, whose `onReady` in progress shutdown waits for, less one that asked the app to stop. */ this.readyPass = new ReadyPass();
573
- /** Whether the providers, services and event handlers are set up, which a retry after a failed login reuses. */ this.prepared = false;
658
+ /** Whether a start has marked where it began and not been undone, so a failed one drops what it made exactly once. */ this.begun = false;
659
+ /** Whether this start's providers and services are made; a failed start drops them, so a retry makes them again. */ this.made = false;
660
+ /** Whether the Discord event handlers are attached, once for the app: they resolve what they run as each event comes. */ this.attached = false;
574
661
  /** Whether the bot logged in, after which `start()` has nothing to do. */ this.online = false;
575
662
  /** Whether a login of this app failed, so a retry restores the client discord.js destroyed. */ this.loginFailed = false;
576
663
  // First, so nothing logged from here on, before login or after, prints it
577
664
  hideInLogs(discordToken);
578
- this.lifecycleUnits = lifecycleUnits ?? classUnits(container, lifecycleClasses);
665
+ this.lifecycleUnits = lifecycleUnits ?? [];
579
666
  this.bot = this.discordClient;
580
667
  // Built now, so a pattern that cannot be read or two that match the same messages or customIds stop the bot as it
581
668
  // is created, before start() attaches anything
@@ -593,7 +680,5 @@ class MeoCordApp {
593
680
  this.shutdownTimeout = shutdownTimeoutOf(shutdownTimeout, (message)=>this.logger.warn(message));
594
681
  }
595
682
  }
596
- /** Whether a failed login set the process exit code, so a later successful one knows to clear it. */ MeoCordApp.failedLoginSetExitCode = false;
597
- /** Whether `meocord start --dev` was told the bot could not log in, so a later login tells it the bot is online. */ MeoCordApp.toldDevRunnerLoginFailed = false;
598
683
 
599
684
  export { MeoCordApp, SLOW_READY_HOOK_MS, shutdownAndExit };
@@ -1,5 +1,5 @@
1
1
  import { fitsScope, commandWordsOf, afterStart } from './message-routes.js';
2
- import { isGuildType, primaryFirst, usageOf, paramTypeLabel } from './message-params.js';
2
+ import { effectiveScope, primaryFirst, usageOf, paramTypeLabel } from './message-params.js';
3
3
  import { splitWords } from './message-words.js';
4
4
  import { usageHeading } from '../common/errors.js';
5
5
  import { textRenderer } from '../common/meocord-text.js';
@@ -31,12 +31,13 @@ import { userWords } from '../util/user-text.util.js';
31
31
  const text = content.trim();
32
32
  const rest = afterStart(text, starts.prefixes.filter((prefix)=>prefix !== ''), starts.mention, caseSensitive);
33
33
  if (!rest) return undefined;
34
- const [first] = splitWords(rest);
34
+ const [first, second] = splitWords(rest);
35
35
  const key = (word)=>caseSensitive ? word : word.toLowerCase();
36
36
  if (!first || !words.some((word)=>key(word) === key(first.value))) return undefined;
37
+ // From where the next word starts, as the help word may have been written in quotes
37
38
  return {
38
39
  start: text.slice(0, text.length - rest.length),
39
- query: rest.slice(first.value.length).trim()
40
+ query: second ? rest.slice(second.start).trim() : ''
40
41
  };
41
42
  }
42
43
  /** The start a caller would type a route with here: its own prefix, a mention, or the start the help message used. */ function displayStart(route, start, starts) {
@@ -45,11 +46,6 @@ import { userWords } from '../util/user-text.util.js';
45
46
  if (route.prefix !== undefined) return route.prefix[0] ?? '';
46
47
  return start;
47
48
  }
48
- /** Where a route works: its scope, narrowed to servers by a `member`, `role` or `channel` param or flag. */ function effectiveScope(route) {
49
- if (route.scope !== 'any') return route.scope;
50
- const guildOnly = route.tokens.some((token)=>'param' in token && token.type !== undefined && isGuildType(token.type)) || route.flags.some((flag)=>flag.type !== undefined && isGuildType(flag.type));
51
- return guildOnly ? 'guild' : 'any';
52
- }
53
49
  /** A route's params and flags, each with what it takes in words. */ function paramsOf(route, types, render) {
54
50
  const params = route.tokens.flatMap((token)=>{
55
51
  if (!('param' in token)) return [];
@@ -294,4 +290,4 @@ let graphemes;
294
290
  return chunks;
295
291
  }
296
292
 
297
- export { computeMessageHelp, effectiveScope, helpInvocation, helpWords, isListable, matchHelpRequest, renderMessageHelp, splitReply };
293
+ export { computeMessageHelp, helpInvocation, helpWords, isListable, matchHelpRequest, renderMessageHelp, splitReply };