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,6 +1,7 @@
1
- import { existsSync, realpathSync, cpSync, readFileSync, readdirSync } from 'fs';
1
+ import { existsSync, realpathSync, cpSync, readdirSync } from 'fs';
2
2
  import path from 'path';
3
- import { currentPlatform } from '../util/platform.util.js';
3
+ import { currentPlatform, LIBC_PLATFORM } from '../util/platform.util.js';
4
+ import { readJsonFile } from '../util/json.util.js';
4
5
 
5
6
  /**
6
7
  * The package a resolved module file belongs to, and the directory it was installed in.
@@ -85,7 +86,7 @@ import { currentPlatform } from '../util/platform.util.js';
85
86
  }
86
87
  /** A package's declared dependencies, runtime and optional, or none if it cannot be read. */ function readDependencies(dir) {
87
88
  try {
88
- const pkg = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
89
+ const pkg = readJsonFile(path.join(dir, 'package.json'));
89
90
  return {
90
91
  dependencies: Object.keys(pkg.dependencies ?? {}),
91
92
  optional: Object.keys(pkg.optionalDependencies ?? {})
@@ -160,12 +161,31 @@ import { currentPlatform } from '../util/platform.util.js';
160
161
  * native package it keeps out is recorded in `found`, so the build can copy it into the output.
161
162
  */ function createNativeExternals(root) {
162
163
  const found = new Map();
164
+ const otherCopies = new Map();
163
165
  const plain = new Set();
166
+ // Where each importing directory finds a native package, read once per directory
167
+ const resolved = new Map();
168
+ const resolveFrom = (name, from)=>{
169
+ const key = `${name}\0${from}`;
170
+ if (!resolved.has(key)) resolved.set(key, resolvePackageDir(name, from, root));
171
+ return resolved.get(key);
172
+ };
164
173
  const externals = ({ request, context }, callback)=>{
165
174
  const name = request ? packageNameOfRequest(request) : undefined;
166
175
  if (!name || plain.has(name)) return callback();
167
- if (found.has(name)) return callback(undefined, request);
168
- const dir = resolvePackageDir(name, context ?? root, root);
176
+ const dir = resolveFrom(name, context ?? root);
177
+ const known = found.get(name);
178
+ if (known) {
179
+ // The bundle loads one copy of it for every importer, so code that needs another installed copy gets this one
180
+ const others = otherCopies.get(name) ?? [];
181
+ if (dir && !samePackageDir(dir, known.dir) && !others.some((other)=>samePackageDir(other, dir))) {
182
+ otherCopies.set(name, [
183
+ ...others,
184
+ dir
185
+ ]);
186
+ }
187
+ return callback(undefined, request);
188
+ }
169
189
  const carrier = dir ? nativeCarrier(dir, name, root) : undefined;
170
190
  if (!dir || !carrier) {
171
191
  plain.add(name);
@@ -179,34 +199,70 @@ import { currentPlatform } from '../util/platform.util.js';
179
199
  };
180
200
  return {
181
201
  externals,
182
- found
202
+ found,
203
+ otherCopies
204
+ };
205
+ }
206
+ /** Whether two directories are one installed package, as pnpm links one from its store under several names. */ function samePackageDir(a, b) {
207
+ const real = (dir)=>{
208
+ try {
209
+ return realpathSync(dir);
210
+ } catch {
211
+ return dir;
212
+ }
213
+ };
214
+ return real(a) === real(b);
215
+ }
216
+ /**
217
+ * The warning for a native package bundled code imports from more than one installed copy: each copy's version and
218
+ * place, and that the one packed is what every importer loads.
219
+ */ function otherCopiesWarning(name, packed, others, root) {
220
+ const where = (dir)=>{
221
+ let version = 'an unknown version';
222
+ try {
223
+ version = String(readJsonFile(path.join(dir, 'package.json')).version ?? version);
224
+ } catch {
225
+ // Unreadable: named by its place alone
226
+ }
227
+ return `${version} in ${path.relative(root, dir) || '.'}`;
183
228
  };
229
+ return `${name} is installed more than once for bundled code: ${[
230
+ packed,
231
+ ...others
232
+ ].map(where).join(', ')}. A self-contained ` + `build packs one copy of a native package, ${where(packed)}, and every importer loads it, so code that needs another ` + `version gets this one. Install one version of ${name}, for example with \`npm dedupe\` or an \`overrides\` entry. ` + 'The next major version (5.0) stops the build here.';
184
233
  }
234
+ /** The sole entry of an `os`, `cpu` or `libc` list that admits every value. */ const ANY_PLATFORM = 'any';
185
235
  /**
186
- * Whether a value passes a package.json `os`, `cpu` or `libc` list, the way npm reads one: a `!`
187
- * entry excludes its value, and a list with plain entries admits only those.
188
- */ function allowedBy(list, value) {
189
- if (!Array.isArray(list) || list.length === 0) return true;
190
- const entries = list.filter((entry)=>typeof entry === 'string');
191
- if (entries.includes(`!${value}`)) return false;
192
- const admitted = entries.filter((entry)=>!entry.startsWith('!'));
193
- return admitted.length === 0 || admitted.includes(value);
236
+ * Whether a value passes a package.json `os`, `cpu` or `libc` field, the way npm reads one (npm-install-checks'
237
+ * `checkList`): a string is a list of one, `['any']` admits every value, a `!` entry excludes its value, and a list with
238
+ * plain entries admits only those.
239
+ */ function allowedBy(field, value) {
240
+ if (!field || typeof field !== 'string' && !Array.isArray(field)) return true;
241
+ const list = typeof field === 'string' ? [
242
+ field
243
+ ] : field.filter((entry)=>typeof entry === 'string');
244
+ if (list.length === 1 && list[0] === ANY_PLATFORM) return true;
245
+ const excluded = list.filter((entry)=>entry.startsWith('!'));
246
+ if (excluded.includes(`!${value}`)) return false;
247
+ return excluded.length === list.length || list.includes(value);
194
248
  }
195
249
  /**
196
250
  * Whether an installed package is built for the platform, going by the `os`, `cpu` and `libc` its
197
251
  * package.json declares. A package that declares none of them runs anywhere.
198
252
  *
199
- * `libc` is compared only when the build platform's C library is known, so a runtime that cannot
200
- * report it keeps the package rather than dropping it on a guess.
253
+ * `libc` is compared only when the build platform's C library is known, so a Linux runtime that
254
+ * cannot report it keeps the package rather than dropping it on a guess. A platform with no C
255
+ * library to choose, such as macOS or Windows, takes no package that names one, as npm reads it.
201
256
  */ function isBuiltFor(dir, platform) {
202
257
  let pkg;
203
258
  try {
204
- pkg = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
259
+ pkg = readJsonFile(path.join(dir, 'package.json'));
205
260
  } catch {
206
261
  return true;
207
262
  }
208
263
  if (!allowedBy(pkg.os, platform.platform) || !allowedBy(pkg.cpu, platform.arch)) return false;
209
- return !platform.libc || allowedBy(pkg.libc, platform.libc);
264
+ if (platform.libc) return allowedBy(pkg.libc, platform.libc);
265
+ return platform.platform === LIBC_PLATFORM || !pkg.libc;
210
266
  }
211
267
  /**
212
268
  * Copies packages and their runtime dependencies into `<outDir>/node_modules`, skipping `@types` and other platforms'
@@ -287,4 +343,4 @@ import { currentPlatform } from '../util/platform.util.js';
287
343
  throw new Error(`bundleDependencies bundled ${found.size === 1 ? 'a package that loads' : 'packages that load'} a native addon:\n` + `${lines.join('\n')}\n` + 'A native addon is a compiled binary for one platform and cannot travel inside the bundle. The build ' + 'would run on this machine and fail in production the first time the addon loads. Keep ' + `${found.size === 1 ? 'it' : 'them'} out of the bundle and install ${found.size === 1 ? 'it' : 'them'} ` + `on the server:\n\n externals: [${names}]`);
288
344
  }
289
345
 
290
- export { assertNoBundledNativeAddons, bundledModuleFiles, copyPackagesInto, createNativeExternals, findBundledNativeAddons, installedPackages, isBuiltFor, nativeCarrier, packageFromPath, packageNameOfRequest };
346
+ export { assertNoBundledNativeAddons, bundledModuleFiles, copyPackagesInto, createNativeExternals, findBundledNativeAddons, installedPackages, isBuiltFor, nativeCarrier, otherCopiesWarning, packageFromPath, packageNameOfRequest };
@@ -2,8 +2,9 @@ import path from 'path';
2
2
  import { fileURLToPath } from 'url';
3
3
  import { OptionalProbesPlugin } from './optional-probes.js';
4
4
  import { RunnableBundlePlugin } from './runnable-bundle.js';
5
- import { typeExternals } from './typed-externals.js';
5
+ import { typeExternals, externalMatcher } from './typed-externals.js';
6
6
  import { prepareModifiedTsConfig } from '../util/tsconfig.util.js';
7
+ import { WEBPACK_HOOK_REFUSAL } from '../util/meocord-config-validation.util.js';
7
8
 
8
9
  /**
9
10
  * The module bundled ahead of the application's entry to load `dist/meocord.config.mjs` first. Shipped
@@ -33,7 +34,8 @@ import { prepareModifiedTsConfig } from '../util/tsconfig.util.js';
33
34
  * The `optionalExternals` also listed in `externals`, where an ESM import of each runs as its importer loads and
34
35
  * throws when the package is missing, defeating the point of listing it as optional; a require stays one either way.
35
36
  */ function optionalExternalConflicts(optionalExternals = [], externals = []) {
36
- return optionalExternals.filter((name)=>externals.some((external)=>typeof external === 'string' ? external === name : external.test(name)));
37
+ const matchers = externals.map(externalMatcher);
38
+ return optionalExternals.filter((name)=>matchers.some((matches)=>matches(name)));
37
39
  }
38
40
  /**
39
41
  * The prefix an asset import is joined to at runtime: the output directory, with forward slashes.
@@ -117,6 +119,12 @@ import { prepareModifiedTsConfig } from '../util/tsconfig.util.js';
117
119
  ]));
118
120
  // Says a missing package a dependency only probes for is harmless, and how to silence it
119
121
  chain.plugin('meocord-optional-probes').use(OptionalProbesPlugin);
122
+ // Project references off, where Rsbuild sets 'auto': with them, Rspack resolves the root project's own files
123
+ // without its paths and baseUrl, which tsc applies, so a bot that references another project couldn't build. A
124
+ // file inside a referenced project then resolves through the root config's paths, not its own
125
+ if (chain.resolve.get('tsConfig')) chain.resolve.tsConfig({
126
+ configFile: tsconfigPath
127
+ });
120
128
  },
121
129
  swc: {
122
130
  jsc: {
@@ -231,7 +239,7 @@ import { prepareModifiedTsConfig } from '../util/tsconfig.util.js';
231
239
  * the default export's properties, but `in` and `Object.keys` only see `default`.
232
240
  */ function assertNoWebpackHook(config) {
233
241
  if (config?.webpack === undefined) return;
234
- throw new Error('meocord.config.ts still declares a `webpack` hook, which MeoCord no longer runs. ' + 'Rename it to `rsbuild` and reshape its body for Rsbuild: images, fonts, svg and media ' + 'need no rules any more, `output.filename` accepts a function for custom asset paths, ' + 'and raw rules go through `tools.rspack`. See the 4.0.0 release notes.');
242
+ throw new Error(WEBPACK_HOOK_REFUSAL);
235
243
  }
236
244
 
237
245
  export { CONFIG_PRE_ENTRY, DISCORD_OPTIONAL_NATIVES, assertNoWebpackHook, assetPrefixFor, createRsbuildConfig, optionalExternalConflicts, optionalExternalNames };
@@ -6,6 +6,19 @@
6
6
  */ function externalType(request, dependencyType) {
7
7
  return `${dependencyType === 'commonjs' ? 'node-commonjs' : 'import'} ${request}`;
8
8
  }
9
+ /**
10
+ * Whether a request matches an `externals` entry: a name it equals, or a pattern. A pattern is tested through a copy of
11
+ * its own, from the start of each request: with the `g` or `y` flag, `test` would otherwise resume where the last match
12
+ * ended and miss the next request for the same package. A `y` pattern still matches only at the start, as Rsbuild
13
+ * tests it.
14
+ */ function externalMatcher(entry) {
15
+ if (typeof entry === 'string') return (request)=>request === entry;
16
+ const pattern = new RegExp(entry);
17
+ return (request)=>{
18
+ pattern.lastIndex = 0;
19
+ return pattern.test(request);
20
+ };
21
+ }
9
22
  /** Whether an external's result names its own type, as `node-commonjs zlib-sync` does. */ const typed = (result)=>/\s/.test(result);
10
23
  /**
11
24
  * The externals with every untyped entry given its type by {@link externalType}: a string or pattern, as `autoExternal`
@@ -13,7 +26,7 @@
13
26
  */ function typeExternals(items) {
14
27
  return items.map((item)=>{
15
28
  if (typeof item === 'string' || item instanceof RegExp) {
16
- const matches = (request)=>typeof item === 'string' ? request === item : item.test(request);
29
+ const matches = externalMatcher(item);
17
30
  return (data, callback)=>{
18
31
  const { request, dependencyType } = data;
19
32
  if (request && matches(request)) return callback(null, externalType(request, dependencyType));
@@ -33,4 +46,4 @@
33
46
  });
34
47
  }
35
48
 
36
- export { externalType, typeExternals };
49
+ export { externalMatcher, externalType, typeExternals };
@@ -1,14 +1,29 @@
1
- /**
2
- * How many calls a cooldown allows, and in how long a window.
3
- *
4
- * @group Types
5
- */ /** `verdict` with `release` added as a non-enumerable property, as the built-in stores give it. */ function withRelease(verdict, release) {
1
+ import { sharedKey } from '../util/shared-state.util.js';
2
+
3
+ /** `verdict` with `release` added as a non-enumerable property, as the built-in stores give it. */ function withRelease(verdict, release) {
6
4
  return Object.defineProperty(verdict, 'release', {
7
5
  value: release,
8
6
  enumerable: false,
9
7
  configurable: true
10
8
  });
11
9
  }
10
+ /**
11
+ * `verdict` with `retryTimestamp` added as a non-enumerable property, unless it holds the field already, so a refusal
12
+ * that gains it compares and serialises as it did.
13
+ */ function withRetryTimestamp(verdict, retryTimestamp) {
14
+ if (retryTimestamp === undefined || Object.hasOwn(verdict, 'retryTimestamp')) return verdict;
15
+ return Object.defineProperty(verdict, 'retryTimestamp', {
16
+ value: retryTimestamp,
17
+ enumerable: false,
18
+ configurable: true
19
+ });
20
+ }
21
+ /** A batch verdict for one key, as the built-in stores' `consume` gives it: `allowed`, `retryAfterMs`, and the wait's end. */ function singleVerdict({ allowed, retryAfterMs, retryTimestamp }) {
22
+ return withRetryTimestamp({
23
+ allowed,
24
+ retryAfterMs
25
+ }, retryTimestamp);
26
+ }
12
27
  /**
13
28
  * Where `@Cooldown` counts calls.
14
29
  *
@@ -59,10 +74,11 @@
59
74
  */ async consumeMany(entries) {
60
75
  for (const [index, { key, limit }] of entries.entries()){
61
76
  const verdict = await this.consume(key, limit);
62
- if (!verdict.allowed) return {
77
+ // The wait's end by name, as a built-in store's consume gives it non-enumerable, which a spread leaves out
78
+ if (!verdict.allowed) return withRetryTimestamp({
63
79
  ...verdict,
64
80
  blocked: index
65
- };
81
+ }, verdict.retryTimestamp);
66
82
  }
67
83
  return {
68
84
  allowed: true,
@@ -88,6 +104,18 @@
88
104
  });
89
105
  }
90
106
  }
107
+ // CooldownStore's own consumeMany and peekMany carry a mark both builds share, so a check can tell a store's
108
+ // override from either build's default, as a store that extends one build's class can be checked by the other's
109
+ const DEFAULT_BATCH_METHOD = sharedKey('cooldown-store:default-batch-method');
110
+ for (const method of [
111
+ CooldownStore.prototype.consumeMany,
112
+ CooldownStore.prototype.peekMany
113
+ ]){
114
+ Object.defineProperty(method, DEFAULT_BATCH_METHOD, {
115
+ value: true
116
+ });
117
+ }
118
+ /** Whether `method` is CooldownStore's own consumeMany or peekMany, from either build of this version. */ const isDefaultBatchMethod = (method)=>typeof method === 'function' && DEFAULT_BATCH_METHOD in method;
91
119
  /**
92
120
  * Of several refusals, the one a caller waits on: the call runs only once every entry allows it, so the
93
121
  * longest wait. Undefined when nothing refused.
@@ -178,16 +206,12 @@
178
206
  * @see {@link CooldownStore}
179
207
  */ class MemoryCooldownStore extends CooldownStore {
180
208
  async consume(key, limit) {
181
- const { allowed, retryAfterMs } = await this.consumeMany([
209
+ return singleVerdict(await this.consumeMany([
182
210
  {
183
211
  key,
184
212
  limit
185
213
  }
186
- ]);
187
- return {
188
- allowed,
189
- retryAfterMs
190
- };
214
+ ]));
191
215
  }
192
216
  /**
193
217
  * Checks every entry, and records the call against all of them only if all allow it.
@@ -274,4 +298,4 @@
274
298
  }
275
299
  }
276
300
 
277
- export { CooldownStore, MemoryCooldownStore, forgetRecorded, longestRefusal, recordedOf, withRelease };
301
+ export { CooldownStore, MemoryCooldownStore, forgetRecorded, isDefaultBatchMethod, longestRefusal, recordedOf, singleVerdict, withRelease };
@@ -1,4 +1,7 @@
1
- const warned = new Set();
1
+ import { shared } from '../util/shared-state.util.js';
2
+
3
+ // One per process for this meocord version, so a warning both builds give prints once
4
+ const warned = shared('deprecationWarnings', ()=>new Set());
2
5
  /** Warns once per run that `old` goes in 5.0, naming what to use instead. */ function warnDeprecated(logger, old, replacement) {
3
6
  warnOnce(logger, `${old} is deprecated and will be removed in the next major version (5.0). Use ${replacement} instead.`);
4
7
  }
@@ -38,8 +38,10 @@ import { useTheme } from '../core/theme-scope.js';
38
38
  /**
39
39
  * The response state of the interaction being handled, the same one `respond(interaction)` returns,
40
40
  * or `undefined` for anything that cannot be answered: a message, a reaction, an event or
41
- * autocomplete.
41
+ * autocomplete. An `@On('interactionCreate')` handler's call has none either, as MeoCord answers that interaction
42
+ * through the handler it dispatches it to.
42
43
  */ get response() {
44
+ if (this.getType() === 'event') return undefined;
43
45
  const interaction = this.getInteraction();
44
46
  return interaction?.isRepliable() ? respond(interaction) : undefined;
45
47
  }
@@ -12,7 +12,10 @@ const EXPLAINED = Symbol.for('meocord.explained');
12
12
  *
13
13
  * Use it where a bot logs a startup failure, so an error MeoCord explained is not logged twice: an app
14
14
  * `MeoCordFactory.create()` refuses, such as two handlers for one command, or Discord refusing the bot token or a
15
- * privileged intent at login. The error is otherwise unchanged.
15
+ * privileged intent at login. A built bot has `process.exitCode` set for such an error already, and a start that a stop
16
+ * ended, which is explained too, leaves it as it was, so the process exits 0. Outside a build, as a script run by hand,
17
+ * `MeoCordFactory.create()` sets no code for an app it refuses, so a catch that returns early for an explained error,
18
+ * as below, sets `process.exitCode` itself there. The error is otherwise unchanged.
16
19
  *
17
20
  * @param error - An error `MeoCordFactory.create()` threw or `app.start()` rejected with.
18
21
  * @returns `true` when MeoCord explained the error.
@@ -28,9 +31,10 @@ const EXPLAINED = Symbol.for('meocord.explained');
28
31
  * await app.start()
29
32
  * }
30
33
  *
31
- * // An error MeoCord explained, such as an app it refuses or a token Discord refused, is already logged
34
+ * // An error MeoCord explained, such as an app it refuses or a token Discord refused, is already logged, with the exit code set
32
35
  * bootstrap().catch(error => {
33
- * if (!isExplainedError(error)) logger.error('Error during startup:', error)
36
+ * if (isExplainedError(error)) return
37
+ * logger.error('Error during startup:', error)
34
38
  * process.exitCode = 1
35
39
  * })
36
40
  * ```
@@ -1,5 +1,6 @@
1
1
  import { loadMeoCordConfig } from '../util/meocord-config-loader.util.js';
2
2
  import { isBuiltApplication } from '../util/bundle-entry.util.js';
3
+ import { shared } from '../util/shared-state.util.js';
3
4
 
4
5
  /** The environment variable that sets the level for one run, over `logLevel` in meocord.config.ts. */ const LOG_LEVEL_ENV = 'MEOCORD_LOG_LEVEL';
5
6
  /** Each level's rank: a line prints when its rank is at least the level's. `silent` outranks every line. */ const LOG_LEVEL_RANK = {
@@ -10,32 +11,35 @@ import { isBuiltApplication } from '../util/bundle-entry.util.js';
10
11
  silent: 4
11
12
  };
12
13
  const isLogLevel = (value)=>typeof value === 'string' && Object.hasOwn(LOG_LEVEL_RANK, value);
13
- let threshold;
14
- let rejectedEnv;
14
+ // One per process for this meocord version, so both builds print at one level and warn about a bad variable once
15
+ const level = shared('logLevel', ()=>({
16
+ threshold: undefined,
17
+ rejectedEnv: undefined
18
+ }));
15
19
  /**
16
20
  * The rank a line needs to print. Resolved on first use and kept, so logging reads neither the
17
21
  * environment nor the config again: `MEOCORD_LOG_LEVEL`, then `logLevel`, then `debug` in development
18
22
  * and `log` otherwise. `logLevel` applies to the built bot only; the CLI and tests would otherwise read
19
23
  * it from whatever `dist` a previous build left.
20
24
  */ function logThreshold() {
21
- if (threshold === undefined) {
25
+ if (level.threshold === undefined) {
22
26
  // The config first: loading it runs its dotenv call, which may set the variable
23
27
  const configured = isBuiltApplication() ? loadMeoCordConfig()?.logLevel : undefined;
24
28
  const raw = process.env[LOG_LEVEL_ENV];
25
29
  // An environment variable is often written in capitals, DEBUG for debug
26
30
  const fromEnv = raw?.toLowerCase();
27
- rejectedEnv = raw && !isLogLevel(fromEnv) ? raw : undefined;
28
- const level = isLogLevel(fromEnv) ? fromEnv : isLogLevel(configured) ? configured : process.env.NODE_ENV === 'development' ? 'debug' : 'log';
29
- threshold = LOG_LEVEL_RANK[level];
31
+ level.rejectedEnv = raw && !isLogLevel(fromEnv) ? raw : undefined;
32
+ const chosen = isLogLevel(fromEnv) ? fromEnv : isLogLevel(configured) ? configured : process.env.NODE_ENV === 'development' ? 'debug' : 'log';
33
+ level.threshold = LOG_LEVEL_RANK[chosen];
30
34
  }
31
- return threshold;
35
+ return level.threshold;
32
36
  }
33
37
  /**
34
38
  * An unknown `MEOCORD_LOG_LEVEL` the last resolution ignored, handed out once so the logger can warn
35
39
  * about it; undefined afterwards.
36
40
  */ function takeRejectedLogLevel() {
37
- const rejected = rejectedEnv;
38
- rejectedEnv = undefined;
41
+ const rejected = level.rejectedEnv;
42
+ level.rejectedEnv = undefined;
39
43
  return rejected;
40
44
  }
41
45
 
@@ -5,7 +5,8 @@ import timezone from 'dayjs/plugin/timezone.js';
5
5
  import { loadMeoCordConfig } from '../util/meocord-config-loader.util.js';
6
6
  import { isBuiltApplication } from '../util/bundle-entry.util.js';
7
7
  import chalk, { chalkStderr } from 'chalk';
8
- import { logThreshold, LOG_LEVEL_ENV, LOG_LEVEL_RANK, takeRejectedLogLevel } from './log-level.js';
8
+ import { logThreshold, takeRejectedLogLevel, LOG_LEVEL_ENV, LOG_LEVEL_RANK } from './log-level.js';
9
+ import { shared } from '../util/shared-state.util.js';
9
10
 
10
11
  dayjs.extend(utc);
11
12
  dayjs.extend(timezone);
@@ -31,7 +32,8 @@ const tagColours = TAG_COLOURS;
31
32
  * The shortest value registered: a bot's credential is far longer, and a short value, such as a test's placeholder,
32
33
  * would replace ordinary words in every line.
33
34
  */ const MIN_HIDDEN_LENGTH = 16;
34
- const hiddenValues = new Set();
35
+ // One per process for this meocord version, so a value hidden through one build stays out of the other's lines
36
+ const hiddenValues = shared('hiddenValues', ()=>new Set());
35
37
  /**
36
38
  * Keeps `value`, such as the bot's credential, out of every line Logger prints from now on, whatever object, string
37
39
  * or error carries it: trimmed, and without the `Bot ` or `Bearer ` prefix discord.js strips.
@@ -7,8 +7,9 @@ const ENGLISH = 'en-US';
7
7
  /**
8
8
  * The message a text is in for `locale`, and whether it is the app's: the app's catalogs down the locale's chain, with
9
9
  * MeoCord's English as the English catalog. An English request reads it after the app's English catalogs and before a
10
- * default in another language; any other request reads it last.
11
- */ function findText(translator, locale, key) {
10
+ * default in another language; any other request reads it last. A message whose form for `count` is empty, as tools
11
+ * export an untranslated string, counts as missing, as it does for the app's own texts.
12
+ */ function findText(translator, locale, key, count) {
12
13
  const english = ()=>{
13
14
  const message = lookup(MEOCORD_MESSAGES, key);
14
15
  return message === undefined ? undefined : {
@@ -23,7 +24,10 @@ const ENGLISH = 'en-US';
23
24
  const beforeDefault = englishFirst && served === translator.defaultLocale ? english() : undefined;
24
25
  if (beforeDefault) return beforeDefault;
25
26
  const message = lookup(translator[CATALOGS][served], key);
26
- if (message !== undefined) return {
27
+ if (message !== undefined && pluralForm({
28
+ message,
29
+ locale: served
30
+ }, count) !== '') return {
27
31
  message,
28
32
  locale: served,
29
33
  own: true
@@ -44,9 +48,9 @@ const lists = new Map();
44
48
  * that has it; any other list is joined with its `joiner`, a comma unless given, as Intl's `unit` lists join with
45
49
  * nothing in zh-CN and with a space in ja.
46
50
  */ function renderText(translator, locale, text) {
47
- const found = findText(translator, locale, text.key);
48
- if (!found) return text.fallback ?? text.key;
49
51
  const params = text.params ?? {};
52
+ const found = findText(translator, locale, text.key, params.count);
53
+ if (!found) return text.fallback ?? text.key;
50
54
  return fillPlaceholders(pluralForm(found, params.count), (name, whole)=>{
51
55
  if (!Object.hasOwn(params, name)) return whole;
52
56
  const value = params[name];
@@ -72,7 +76,8 @@ const translators = shared('clientTranslators', ()=>new WeakMap());
72
76
  /** A text for an interaction, in its user's language through its app's translator. */ function textFor(interaction, text) {
73
77
  return renderText(translatorOfClient(interaction.client), interactionLocale(interaction), text);
74
78
  }
75
- const issueTexts = new WeakMap();
79
+ // One per process for this meocord version, so an issue made by one build is translated by the other
80
+ const issueTexts = shared('issueTexts', ()=>new WeakMap());
76
81
  /** An issue of a usage error, whose message is the text in English, keeping the text to translate it later. */ function usageIssue(text, param) {
77
82
  const issue = {
78
83
  ...param !== undefined && {
@@ -1,5 +1,5 @@
1
1
  import { randomUUID, createHash } from 'node:crypto';
2
- import { CooldownStore, withRelease, longestRefusal } from './cooldown-store.js';
2
+ import { CooldownStore, singleVerdict, withRelease, longestRefusal } from './cooldown-store.js';
3
3
  import { Logger } from './logger.js';
4
4
 
5
5
  const logger = new Logger('RedisCooldownStore');
@@ -138,16 +138,12 @@ const DEFAULT_PREFIX = 'meocord:cooldown:';
138
138
  * @param limit - The calls allowed, and the window they are counted over.
139
139
  * @returns Whether this call was recorded, and if not, how long until one can be.
140
140
  */ async consume(key, limit) {
141
- const { allowed, retryAfterMs } = await this.consumeMany([
141
+ return singleVerdict(await this.consumeMany([
142
142
  {
143
143
  key,
144
144
  limit
145
145
  }
146
- ]);
147
- return {
148
- allowed,
149
- retryAfterMs
150
- };
146
+ ]));
151
147
  }
152
148
  /**
153
149
  * Records a call against every entry if all allow it, as one script: one round trip, however many
@@ -31,5 +31,21 @@ const { Ephemeral, SuppressEmbeds, SuppressNotifications, IsComponentsV2 } = Mes
31
31
  /** The names of the flags set in a value, for warnings. */ function flagNames(flags) {
32
32
  return new MessageFlagsBitField(flags).toArray();
33
33
  }
34
+ /** Each call as a warning names it, its article included. */ const STEP_NAMES = {
35
+ deferReply: 'a deferred reply',
36
+ reply: 'a reply',
37
+ update: 'an update',
38
+ edit: 'an edit',
39
+ followUp: 'a follow-up'
40
+ };
41
+ /** What an update or an edit does with `Ephemeral` instead, and how to answer only the member. */ const PRIVATE_HINT = {
42
+ update: 'An update changes the message the member clicked, for everyone; to answer only them, use respond(interaction).followUp() with MessageFlags.Ephemeral.',
43
+ edit: 'An edit keeps the message as public or private as it was; to answer only the member, use respond(interaction).followUp() with MessageFlags.Ephemeral.'
44
+ };
45
+ /** The development warning for the flags a call dropped, with how to answer privately when `Ephemeral` is one of them. */ function droppedFlagsWarning(step, dropped) {
46
+ const text = `Dropped flags ${flagNames(dropped).join(', ')}, which ${STEP_NAMES[step]} cannot take.`;
47
+ const hint = hasEphemeral(dropped) ? PRIVATE_HINT[step] : undefined;
48
+ return hint ? `${text} ${hint}` : text;
49
+ }
34
50
 
35
- export { flagNames, hasComponentsV2, hasEphemeral, resolveFlags };
51
+ export { droppedFlagsWarning, flagNames, hasComponentsV2, hasEphemeral, resolveFlags };
@@ -1,4 +1,4 @@
1
- import { ApplicationIntegrationType, InteractionContextType } from 'discord.js';
1
+ import { InteractionContextType, ApplicationIntegrationType } from 'discord.js';
2
2
 
3
3
  /**
4
4
  * Reports where an interaction happened and whether the bot is present there.
@@ -26,36 +26,47 @@ import { ApplicationIntegrationType, InteractionContextType } from 'discord.js';
26
26
  *
27
27
  * @group Utilities
28
28
  */ function getInstallContext(interaction) {
29
- const owners = interaction.authorizingIntegrationOwners ?? {};
30
- const guildInstalled = owners[ApplicationIntegrationType.GuildInstall] !== undefined;
31
- const userInstalled = owners[ApplicationIntegrationType.UserInstall] !== undefined;
29
+ const botInstalled = isBotInstalled(interaction);
32
30
  switch(interaction.context){
33
31
  case InteractionContextType.BotDM:
34
32
  return {
35
33
  where: 'bot-dm',
36
- botInstalled: true
34
+ botInstalled
37
35
  };
38
36
  case InteractionContextType.PrivateChannel:
39
37
  return {
40
38
  where: 'private-channel',
41
- botInstalled: false
39
+ botInstalled
42
40
  };
43
41
  case InteractionContextType.Guild:
44
- // With no owners reported, the interaction came through the bot, as every interaction did before user installs.
45
42
  return {
46
43
  where: 'guild',
47
- botInstalled: guildInstalled || !userInstalled
44
+ botInstalled
48
45
  };
49
46
  default:
50
- // Discord omits the context for interactions it created before contexts existed, which reached only the bot.
51
- return interaction.guildId ? {
52
- where: 'guild',
53
- botInstalled: true
54
- } : {
55
- where: 'bot-dm',
56
- botInstalled: true
47
+ return {
48
+ where: interaction.guildId ? 'guild' : 'bot-dm',
49
+ botInstalled
57
50
  };
58
51
  }
59
52
  }
53
+ /**
54
+ * Whether the bot is present where an interaction happened, as {@link getInstallContext} reports it, read from Discord's
55
+ * `context` and the integrations that authorized it alone: no `guildId`, which only `where` needs.
56
+ */ function isBotInstalled(interaction) {
57
+ switch(interaction.context){
58
+ case InteractionContextType.PrivateChannel:
59
+ return false;
60
+ case InteractionContextType.Guild:
61
+ {
62
+ const owners = interaction.authorizingIntegrationOwners ?? {};
63
+ // With no owners reported, the interaction came through the bot, as every interaction did before user installs
64
+ return owners[ApplicationIntegrationType.GuildInstall] !== undefined || owners[ApplicationIntegrationType.UserInstall] === undefined;
65
+ }
66
+ default:
67
+ // A DM with the bot, or an interaction from before contexts existed, which reached only the bot
68
+ return true;
69
+ }
70
+ }
60
71
 
61
- export { getInstallContext };
72
+ export { getInstallContext, isBotInstalled };
@@ -24,8 +24,12 @@ import { shared } from '../../util/shared-state.util.js';
24
24
  /** The id MeoCord gives the containers it renders, so a view left behind can be found again. */ const RENDERED_CONTAINER_ID = 0x4d43;
25
25
  // Shared by both builds of this version, so either answers with the app's presenter
26
26
  const presenters = shared('clientPresenters', ()=>new WeakMap());
27
- /** Sets the presenter for the interactions a client receives. */ function setPresenter(client, presenter) {
28
- presenters.set(client, presenter);
27
+ /** Sets the presenter for the interactions a client receives; without one, they take the default. */ function setPresenter(client, presenter) {
28
+ if (presenter) presenters.set(client, presenter);
29
+ else presenters.delete(client);
30
+ }
31
+ /** The presenter set on a client, if any. */ function clientPresenter(client) {
32
+ return presenters.get(client);
29
33
  }
30
34
  /** The presenter for an interaction's client, or the default one. */ function presenterFor(client) {
31
35
  return client && presenters.get(client) || defaultPresenter;
@@ -185,4 +189,4 @@ const IMAGE = /\.(png|jpe?g|gif|webp)$/i;
185
189
  };
186
190
  }
187
191
 
188
- export { ATTACHMENT_LIMIT, DEFAULT_ATTACHMENT_SIZE_LIMIT, REFUSED_AS_TOO_LARGE, RENDERED_CONTAINER_ID, TEXT_LIMITS, attachmentsOf, defaultPresenter, filesProblem, fitText, isTooLarge, presentedMessage, presenterFor, renderContainer, renderEmbed, setPresenter, withSendableFiles, withoutFiles };
192
+ export { ATTACHMENT_LIMIT, DEFAULT_ATTACHMENT_SIZE_LIMIT, REFUSED_AS_TOO_LARGE, RENDERED_CONTAINER_ID, TEXT_LIMITS, attachmentsOf, clientPresenter, defaultPresenter, filesProblem, fitText, isTooLarge, presentedMessage, presenterFor, renderContainer, renderEmbed, setPresenter, withSendableFiles, withoutFiles };