meocord 4.1.2 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +362 -0
  2. package/THIRD_PARTY_NOTICES.md +14 -13
  3. package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-XKMlcUZz.cjs} +669 -185
  4. package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-BZgJyfZS.cjs} +218 -75
  5. package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-CsJJyCZd.cjs} +10 -7
  6. package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CgfPQrNl.cjs} +374 -132
  7. package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-BUAR1WYG.cjs} +21 -7
  8. package/dist/cjs/common/index.cjs +4 -4
  9. package/dist/cjs/core/index.cjs +365 -310
  10. package/dist/cjs/decorator/index.cjs +85 -61
  11. package/dist/cjs/testing/index.cjs +2236 -496
  12. package/dist/cli.json +1 -1
  13. package/dist/esm/bin/app-template/README.md.template +3 -1
  14. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +8 -4
  15. package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +2 -2
  16. package/dist/esm/bin/app-template/src/guards/owner.guard.spec.ts.template +8 -4
  17. package/dist/esm/bin/app-template/src/guards/owner.guard.ts.template +1 -1
  18. package/dist/esm/bin/app-template/vitest.config.ts.template +1 -1
  19. package/dist/esm/bin/app-template/vitest.setup.ts.template +7 -3
  20. package/dist/esm/bin/generator.js +4 -5
  21. package/dist/esm/bin/helper/filter-generator.helper.js +4 -5
  22. package/dist/esm/bin/helper/guard-generator.helper.js +4 -5
  23. package/dist/esm/bin/helper/interceptor-generator.helper.js +4 -5
  24. package/dist/esm/bin/helper/observer-generator.helper.js +4 -5
  25. package/dist/esm/bin/helper/pipe-generator.helper.js +4 -5
  26. package/dist/esm/bin/helper/service-generator.helper.js +4 -5
  27. package/dist/esm/bin/meocord.js +84 -25
  28. package/dist/esm/build/load-config.pre-entry.js +17 -4
  29. package/dist/esm/build/rsbuild-config.js +16 -12
  30. package/dist/esm/build/typed-externals.js +36 -0
  31. package/dist/esm/common/cooldown-store.js +10 -2
  32. package/dist/esm/common/decorator.js +6 -6
  33. package/dist/esm/common/deprecation.js +2 -2
  34. package/dist/esm/common/errors.js +27 -2
  35. package/dist/esm/common/execution-context.js +1 -1
  36. package/dist/esm/common/meocord-text.js +11 -8
  37. package/dist/esm/common/metadata.js +1 -1
  38. package/dist/esm/common/response/call-order.js +9 -4
  39. package/dist/esm/common/response/presenter.js +33 -6
  40. package/dist/esm/common/response/response-state.js +84 -10
  41. package/dist/esm/common/route.js +21 -5
  42. package/dist/esm/common/sharded-cooldown-store.js +1 -1
  43. package/dist/esm/common/translate-error.js +1 -1
  44. package/dist/esm/common/translator.js +37 -6
  45. package/dist/esm/core/command-conflicts.js +34 -34
  46. package/dist/esm/core/command-registration.js +93 -24
  47. package/dist/esm/core/command-routes.js +2 -2
  48. package/dist/esm/core/component-routes.js +74 -31
  49. package/dist/esm/core/cooldown-runner.js +29 -8
  50. package/dist/esm/core/dispatcher.js +51 -7
  51. package/dist/esm/core/fallback.js +20 -11
  52. package/dist/esm/core/filter-runner.js +2 -2
  53. package/dist/esm/core/guard-runner.js +16 -3
  54. package/dist/esm/core/handler-input.js +4 -3
  55. package/dist/esm/core/handler-pipeline.js +27 -14
  56. package/dist/esm/core/handler-registry.js +1 -1
  57. package/dist/esm/core/interceptor-runner.js +2 -2
  58. package/dist/esm/core/lifecycle-hooks.js +134 -47
  59. package/dist/esm/core/meocord-factory.js +241 -179
  60. package/dist/esm/core/meocord.app.js +116 -75
  61. package/dist/esm/core/message-routes.js +4 -3
  62. package/dist/esm/core/observer-runner.js +4 -3
  63. package/dist/esm/core/providers.js +45 -23
  64. package/dist/esm/core/route-specificity.js +4 -4
  65. package/dist/esm/core/running-calls.js +43 -0
  66. package/dist/esm/core/scalar-types.js +15 -3
  67. package/dist/esm/core/shard-context.js +6 -7
  68. package/dist/esm/core/shard-manager.js +16 -10
  69. package/dist/esm/core/startup-roots.js +23 -4
  70. package/dist/esm/core/theme-resolvers.js +4 -1
  71. package/dist/esm/core/theme-runtime.js +2 -2
  72. package/dist/esm/core/theme-scope.js +3 -1
  73. package/dist/esm/core/theme-validation.js +17 -10
  74. package/dist/esm/decorator/app.decorator.js +12 -7
  75. package/dist/esm/decorator/command-builder.decorator.js +4 -4
  76. package/dist/esm/decorator/controller-class.decorator.js +9 -4
  77. package/dist/esm/decorator/controller.decorator.js +162 -83
  78. package/dist/esm/decorator/cooldown.decorator.js +20 -3
  79. package/dist/esm/decorator/defer.decorator.js +4 -4
  80. package/dist/esm/decorator/event.decorator.js +5 -5
  81. package/dist/esm/decorator/filter.decorator.js +7 -7
  82. package/dist/esm/decorator/guard.decorator.js +11 -10
  83. package/dist/esm/decorator/inject.decorator.js +4 -4
  84. package/dist/esm/decorator/interceptor.decorator.js +7 -7
  85. package/dist/esm/decorator/observer.decorator.js +4 -4
  86. package/dist/esm/decorator/service.decorator.js +4 -4
  87. package/dist/esm/decorator/theme.decorator.js +3 -3
  88. package/dist/esm/decorator/validation.decorator.js +10 -10
  89. package/dist/esm/package.json.js +1 -1
  90. package/dist/esm/testing/catalog.js +27 -11
  91. package/dist/esm/testing/cooldown-store-suite.js +85 -15
  92. package/dist/esm/testing/discord-defaults.js +249 -21
  93. package/dist/esm/testing/index.js +4 -2
  94. package/dist/esm/testing/inspect-handler.js +20 -3
  95. package/dist/esm/testing/meocord-testing-module.js +352 -205
  96. package/dist/esm/testing/mock-fn.js +173 -22
  97. package/dist/esm/testing/mock-interaction.js +1214 -209
  98. package/dist/esm/testing/response.js +1 -1
  99. package/dist/esm/testing/routing.js +39 -14
  100. package/dist/esm/testing/snowflake.js +9 -2
  101. package/dist/esm/testing/startup-errors.js +26 -0
  102. package/dist/esm/testing/strict-mocks.js +65 -0
  103. package/dist/esm/testing/theme.js +1 -1
  104. package/dist/esm/util/bundle-entry.util.js +2 -1
  105. package/dist/esm/util/common.util.js +47 -19
  106. package/dist/esm/util/dev-runner.util.js +8 -8
  107. package/dist/esm/util/generator-cli.util.js +4 -5
  108. package/dist/esm/util/interaction.util.js +3 -3
  109. package/dist/esm/util/meocord-config-loader.util.js +10 -2
  110. package/dist/esm/util/meocord-config-validation.util.js +4 -5
  111. package/dist/esm/util/meocord-source-config.util.js +65 -20
  112. package/dist/esm/util/metadata-keys.js +1 -0
  113. package/dist/esm/util/method-wrapper.util.js +16 -0
  114. package/dist/esm/util/platform.util.js +10 -10
  115. package/dist/esm/util/refusal.util.js +148 -8
  116. package/dist/esm/util/shard-process.util.js +4 -1
  117. package/dist/esm/util/sharding-mode.util.js +5 -7
  118. package/dist/esm/util/shared-state.util.js +21 -0
  119. package/dist/esm/util/tsconfig-paths.util.js +29 -0
  120. package/dist/esm/util/tsconfig.util.js +10 -10
  121. package/dist/esm/util/value.util.js +12 -1
  122. package/dist/types/common/index.d.cts +8 -8
  123. package/dist/types/common/index.d.ts +8 -8
  124. package/dist/types/core/index.d.cts +10 -7
  125. package/dist/types/core/index.d.ts +10 -7
  126. package/dist/types/decorator/index.d.cts +31 -29
  127. package/dist/types/decorator/index.d.ts +31 -29
  128. package/dist/types/{index-D7n0FBMm.d.ts → index-DKJclIi-.d.ts} +107 -36
  129. package/dist/types/{index-DEP0NYrU.d.cts → index-Dlf0ZZtS.d.cts} +107 -36
  130. package/dist/types/interface/index.d.cts +1 -1
  131. package/dist/types/interface/index.d.ts +1 -1
  132. package/dist/types/{route-C-aMI7Sl.d.cts → route-Bh1U13yy.d.cts} +8 -4
  133. package/dist/types/{route-C-aMI7Sl.d.ts → route-Bh1U13yy.d.ts} +8 -4
  134. package/dist/types/testing/index.d.cts +406 -70
  135. package/dist/types/testing/index.d.ts +406 -70
  136. package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-CyEz9pnv.d.cts} +1 -1
  137. package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-DA204O-q.d.ts} +1 -1
  138. package/dist/types/{translator-J6tIUfdp.d.ts → translator-Bg7hwxDF.d.cts} +21 -8
  139. package/dist/types/{translator-B4mc1lzJ.d.cts → translator-CeP4bCzp.d.ts} +21 -8
  140. package/package.json +4 -3
@@ -1,6 +1,7 @@
1
1
  import { Locale } from 'discord.js';
2
2
  import { refuse } from '../util/refusal.util.js';
3
3
  import { Logger } from './logger.js';
4
+ import { sharedName } from '../util/shared-state.util.js';
4
5
 
5
6
  /**
6
7
  * Declares a message catalog, keeping each message's text as its type so the params it takes can be checked.
@@ -61,8 +62,8 @@ import { Logger } from './logger.js';
61
62
  /**
62
63
  * Where a translator made by `createTranslator` keeps its catalogs, for MeoCord's texts, `@MeoCord` and
63
64
  * `expectCompleteCatalog`.
64
- */ const CATALOGS = Symbol('catalogs');
65
- /** How MeoCord's own texts read a translator made by `createTranslator`: the catalogs that serve a locale, in order. */ const LOCALE_CHAIN = Symbol('localeChain');
65
+ */ const CATALOGS = Symbol.for(sharedName('translator:catalogs'));
66
+ /** How MeoCord's own texts read a translator made by `createTranslator`: the catalogs that serve a locale, in order. */ const LOCALE_CHAIN = Symbol.for(sharedName('translator:localeChain'));
66
67
  /** For a class that injects `Translator` in an app that configured none. */ function missingTranslatorError(cls) {
67
68
  return new Error(`${cls.name}: it injects Translator, but @MeoCord has no i18n. Pass @MeoCord({ i18n: t }), where t comes from createTranslator.`);
68
69
  }
@@ -109,6 +110,22 @@ function interpolate(message, params) {
109
110
  }
110
111
  return typeof current === 'object' && current !== null;
111
112
  }
113
+ /** The locale Discord shows a locale's users when that locale has no localization of its own, by its locale fallbacks. */ const FALLBACK_PARTNERS = {
114
+ [Locale.EnglishUS]: Locale.EnglishGB,
115
+ [Locale.EnglishGB]: Locale.EnglishUS,
116
+ [Locale.SpanishLATAM]: Locale.SpanishES
117
+ };
118
+ /** Each key with a `.` in it, which `lookup` splits as a path and so never reaches, with the group it sits in. */ function dottedKeys(catalog, group = '') {
119
+ return Object.entries(catalog).flatMap(([name, value])=>[
120
+ ...name.includes('.') ? [
121
+ {
122
+ name,
123
+ group
124
+ }
125
+ ] : [],
126
+ ...typeof value === 'object' && value !== null ? dottedKeys(value, group ? `${group}.${name}` : name) : []
127
+ ]);
128
+ }
112
129
  class CatalogTranslator extends Translator {
113
130
  /** The catalogs that serve a locale, most specific first, ending with the default. */ [LOCALE_CHAIN](requested) {
114
131
  const chain = [];
@@ -127,10 +144,13 @@ class CatalogTranslator extends Translator {
127
144
  translate(requested, key, params = {}) {
128
145
  for (const locale of this[LOCALE_CHAIN](requested)){
129
146
  const message = lookup(this.catalogs[locale], key);
130
- if (message !== undefined) return interpolate(pluralForm({
147
+ if (message === undefined) continue;
148
+ // An empty translation, as tools export an untranslated string, counts as missing: Discord refuses an empty reply
149
+ const text = pluralForm({
131
150
  message,
132
151
  locale
133
- }, params.count), params);
152
+ }, params.count);
153
+ if (text !== '') return interpolate(text, params);
134
154
  }
135
155
  this.warnUnknown(key);
136
156
  return key;
@@ -160,11 +180,16 @@ class CatalogTranslator extends Translator {
160
180
  for (const locale of this.locales){
161
181
  if (locale === this.defaultLocale) continue;
162
182
  const message = lookup(this.catalogs[locale], key);
163
- // A translation with a {param} is left out: Discord would show it as written
164
- if (typeof message === 'string' && placeholderNames(message).length === 0) {
183
+ // A translation with a {param} is left out, as Discord would show it as written, and so is an empty one
184
+ if (typeof message === 'string' && message !== '' && placeholderNames(message).length === 0) {
165
185
  localized[locale] = fillPlaceholders(message, (_name, whole)=>whole);
166
186
  }
167
187
  }
188
+ // Discord gives a locale it falls back from its partner's value, so the default keeps its own wording beside one
189
+ const partner = FALLBACK_PARTNERS[this.defaultLocale];
190
+ if (partner && localized[partner] !== undefined && typeof original === 'string' && original !== '') {
191
+ localized[this.defaultLocale] = fillPlaceholders(original, (_name, whole)=>whole);
192
+ }
168
193
  return localized;
169
194
  }
170
195
  for(interaction, options = {}) {
@@ -243,6 +268,12 @@ class CatalogTranslator extends Translator {
243
268
  }
244
269
  }
245
270
  if (!locales[defaultLocale]) throw refuse(new Error(`The default locale "${defaultLocale}" has no catalog in locales.`));
271
+ const logger = new Logger('Translator');
272
+ for (const [locale, catalog] of Object.entries(locales)){
273
+ for (const { name, group } of dottedKeys(catalog)){
274
+ logger.warn(`The ${locale} catalog's key "${name}"${group ? ` in ${group}` : ''} has a ".", which a lookup reads as a path, so its ` + 'message is never found. Nest it as a group instead.');
275
+ }
276
+ }
246
277
  // Typed by the default catalog; at runtime every catalog is read the same way.
247
278
  return new CatalogTranslator(defaultLocale, locales);
248
279
  }
@@ -1,11 +1,12 @@
1
1
  import { ApplicationCommandType, ApplicationCommandOptionType } from 'discord.js';
2
2
  import { Logger } from '../common/logger.js';
3
- import { getCommandMap, getAutocompleteHandlers, getDeclaredRoutes, getHandlerRoutes, getMessageHandlers, getReactionHandlers } from '../decorator/controller.decorator.js';
3
+ import { getAutocompleteHandlers, getCommandMap, getDeclaredRoutes, getHandlerRoutes, getMessageHandlers, getReactionHandlers } from '../decorator/controller.decorator.js';
4
4
  import { serialise, registrationKey, SENT_AS, commandNameOf } from './command-registration.js';
5
- import { buildComponentRoutes, findComponentRouteConflicts, literalFirst } from './component-routes.js';
5
+ import { buildComponentRoutes, findComponentRouteConflicts } from './component-routes.js';
6
6
  import { CommandType } from '../enum/controller.enum.js';
7
7
  import { isCustomIdRouted } from '../util/interaction.util.js';
8
- import { refuse } from '../util/refusal.util.js';
8
+ import { startupError } from '../util/refusal.util.js';
9
+ import { META } from '../util/metadata-keys.js';
9
10
  import { listed } from '../util/user-text.util.js';
10
11
 
11
12
  const where = ({ controllerClass, meta })=>`${controllerClass.name}.${meta.methodName}`;
@@ -28,7 +29,7 @@ const where = ({ controllerClass, meta })=>`${controllerClass.name}.${meta.metho
28
29
  * Refuses commands of which only one could run or be registered: two handlers of one slash command name or subcommand
29
30
  * path or of one context menu name and kind, or two builders of one command; a command's subcommands, and a user and a
30
31
  * message context menu of one name, are no clash. The app, `meocord register`, the shard manager and testing call it.
31
- * @throws Error naming both handlers, or both builders, with what to do instead.
32
+ * Each clash is a startup error, naming both handlers or both builders with what to do instead; see `startupError`.
32
33
  */ function assertDistinctCommands(controllerClasses) {
33
34
  const handlers = new Map();
34
35
  const builders = new Map();
@@ -46,7 +47,8 @@ const where = ({ controllerClass, meta })=>`${controllerClass.name}.${meta.metho
46
47
  const key = `${meta.type}\0${name}`;
47
48
  const earlier = (handlers.get(key) ?? []).find((other)=>overlap(other.meta, meta));
48
49
  if (earlier) {
49
- throw refuse(new Error(`${where(earlier)}: it and ${where(here)} both handle the ${describe(meta.type, name, contextMenuKind(meta) ?? contextMenuKind(earlier.meta))}, ` + `so only ${where(earlier)} would ever run. Keep one handler for it, or give the other a name or ` + `subcommand path of its own.`));
50
+ startupError(new Error(`${where(earlier)}: it and ${where(here)} both handle the ${describe(meta.type, name, contextMenuKind(meta) ?? contextMenuKind(earlier.meta))}, ` + `so only ${where(earlier)} would ever run. Keep one handler for it, or give the other a name or ` + `subcommand path of its own.`));
51
+ continue;
50
52
  }
51
53
  handlers.set(key, [
52
54
  ...handlers.get(key) ?? [],
@@ -67,7 +69,7 @@ const where = ({ controllerClass, meta })=>`${controllerClass.name}.${meta.metho
67
69
  name: typeof body.name === 'string' ? body.name : name
68
70
  });
69
71
  } else if (built.meta.builderClass !== meta.builderClass) {
70
- throw refuse(new Error(`${where(built)}: its builder ${built.meta.builderClass.name} and ${meta.builderClass.name} on ${where(here)} both ` + `build the ${describe(meta.type, built.name, contextMenuKind(meta))}, and Discord registers one command ` + `per name and type, so only the first would be. Keep one builder, on a single @Command, and declare ` + `the other handlers with CommandType.${meta.type}.`));
72
+ startupError(new Error(`${where(built)}: its builder ${built.meta.builderClass.name} and ${meta.builderClass.name} on ${where(here)} both ` + `build the ${describe(meta.type, built.name, contextMenuKind(meta))}, and Discord registers one command ` + `per name and type, so only the first would be. Keep one builder, on a single @Command, and declare ` + `the other handlers with CommandType.${meta.type}.`));
71
73
  }
72
74
  }
73
75
  }
@@ -245,18 +247,21 @@ const quoted = (names)=>names.length < 2 ? names.map((name)=>`"${name}"`).join('
245
247
  /**
246
248
  * Warns about a handler a subclass re-declares on other routes while it still answers the ones it inherits, for
247
249
  * `@Command`, `@MessageHandler`, `@ReactionHandler` and `@Autocomplete` alike. The next major version (5.0) drops
248
- * the inherited routes. Every class a listed controller extends is checked once, a base that is not listed included.
250
+ * the inherited routes. Every class a listed controller extends is checked, a base that is not listed included, for
251
+ * the methods the controller still inherits through it.
249
252
  */ function warnInheritedRoutes(controllerClasses) {
250
- const checked = new Set();
251
- const problems = [];
253
+ // A set, so a class two listed controllers extend is named once, for the routes either of them still answers
254
+ const problems = new Set();
252
255
  for (const controllerClass of controllerClasses){
256
+ // Methods a class below re-declares with @Controller({ inheritedRoutes: 'replace' }): the listed controller inherits
257
+ // nothing of them from above it, so a class above is not named for them
258
+ const cut = new Set();
253
259
  for(let prototype = controllerClass.prototype; prototype && prototype !== Object.prototype; prototype = Object.getPrototypeOf(prototype)){
254
- if (checked.has(prototype)) break;
255
- checked.add(prototype);
256
260
  const declared = getDeclaredRoutes(prototype);
257
261
  const base = Object.getPrototypeOf(prototype);
258
262
  if (declared.length === 0 || !base) continue;
259
- const inherited = getHandlerRoutes(base);
263
+ if (Reflect.getOwnMetadata(META.replacesInheritedRoutes, prototype)) for (const route of declared)cut.add(route.method);
264
+ const inherited = getHandlerRoutes(base).filter((route)=>!cut.has(route.method));
260
265
  for (const method of new Set(declared.map((route)=>route.method))){
261
266
  const own = [
262
267
  ...new Set(declared.filter((route)=>route.method === method).map((route)=>route.label))
@@ -266,17 +271,19 @@ const quoted = (names)=>names.length < 2 ? names.map((name)=>`"${name}"`).join('
266
271
  ].filter((label)=>!own.includes(label));
267
272
  if (kept.length === 0) continue;
268
273
  const name = prototype.constructor.name;
269
- problems.push(` ${name}.${method} answers ${listed(kept)}, which it inherits, as well as its own ${listed(own)}.`);
274
+ problems.add(` ${name}.${method} answers ${listed(kept)}, which it inherits, as well as its own ${listed(own)}.`);
270
275
  }
271
276
  }
272
277
  }
273
- if (problems.length === 0) return;
274
- const one = problems.length === 1;
275
- logger.warn(`${problems.length} re-declared ${one ? 'handler still answers routes it inherits' : 'handlers still answer routes they inherit'}:\n` + `${problems.join('\n')}\nIn the next major version (5.0), a handler's own routes replace the ones it inherits. ` + "To keep an inherited route, declare it on the subclass's method as well.");
278
+ if (problems.size === 0) return;
279
+ const one = problems.size === 1;
280
+ logger.warn(`${problems.size} re-declared ${one ? 'handler still answers routes it inherits' : 'handlers still answer routes they inherit'}:\n` + `${[
281
+ ...problems
282
+ ].join('\n')}\nIn the next major version (5.0), a handler's own routes replace the ones it inherits. ` + "To keep an inherited route, declare it on the subclass's method as well; to drop it now, give the subclass " + "@Controller({ inheritedRoutes: 'replace' }).");
276
283
  }
277
284
  /**
278
- * Warns of customId patterns of one component type that can match one customId, naming the handler that runs and why;
279
- * an app whose patterns overlap works, so refusing to start would turn a latent mis-route into an outage.
285
+ * Warns of customId patterns of one component type that rank equally and can match one customId, naming an id both
286
+ * match and the handler that runs. An app with such a pair works, so it starts; the next major version (5.0) refuses.
280
287
  * @throws Error for two handlers whose patterns match the same customIds, which the app refuses as well.
281
288
  */ function warnOverlappingPatterns(controllerClasses) {
282
289
  const routes = buildComponentRoutes(controllerClasses);
@@ -286,23 +293,16 @@ const quoted = (names)=>names.length < 2 ? names.map((name)=>`"${name}"`).join('
286
293
  `${route.meta.type}\0${route.pattern}`,
287
294
  route
288
295
  ]));
289
- const lines = conflicts.map(({ type, patterns: [left, right] })=>{
290
- const outcome = ambiguityOutcome(routeOf.get(`${type}\0${left}`), routeOf.get(`${type}\0${right}`));
291
- return ` "${left}" vs "${right}": ${outcome}`;
292
- });
293
- logger.warn(`${conflicts.length} pattern pair(s) can match the same customId, so which one runs is decided by ranking rather ` + `than by the ids themselves:\n${lines.join('\n')}`);
294
- }
295
- /**
296
- * Which of two overlapping routes runs for the ids both match, and why: `runs` ranks first. Between equally
297
- * specific patterns, the order they are listed in decides until 5.0, which prefers the earlier literal segment.
298
- */ function ambiguityOutcome(runs, other) {
299
296
  const name = ({ controllerClass, meta })=>`${controllerClass.name}.${meta.methodName}`;
300
- if ((runs.meta.specificity ?? 0) !== (other.meta.specificity ?? 0)) return `${name(runs)} runs, as its pattern is more specific.`;
301
- const together = runs.controllerClass === other.controllerClass;
302
- const listed = together ? 'it is declared first' : 'its controller is listed first';
303
- if (!literalFirst(other.pattern, runs.pattern)) return `${name(runs)} runs, as ${listed}.`;
304
- const reorder = together ? `Declare ${name(other)} first` : `List ${other.controllerClass.name} first`;
305
- return `${name(runs)} runs, as ${listed}. In the next major version (5.0), ${name(other)} runs instead, as ` + `"${other.pattern}" spells out the first segment where the two differ. ${reorder}, or make the patterns distinct.`;
297
+ const lines = conflicts.map(({ type, patterns: [left, right], customId })=>{
298
+ const [runs, other] = [
299
+ routeOf.get(`${type}\0${left}`),
300
+ routeOf.get(`${type}\0${right}`)
301
+ ];
302
+ const listed = runs.controllerClass === other.controllerClass ? 'it is declared first' : 'its controller is listed first';
303
+ return ` "${left}" vs "${right}", which both match "${customId}": ${name(runs)} runs, as ${listed}.`;
304
+ });
305
+ logger.warn(`${conflicts.length} pattern pair(s) rank equally and can match the same customId, so the order they are listed in ` + `decides which one runs:\n${lines.join('\n')}\nMake the patterns distinct: in the next major version (5.0), ` + 'MeoCord refuses to start with such a pair.');
306
306
  }
307
307
 
308
308
  export { assertDistinctCommands, messageDecorator, reactionDecorator, warnHandlersOffControllers, warnInheritedRoutes, warnOverlappingPatterns, warnUnregisteredCommands };
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'crypto';
2
- import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
2
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, rmSync } from 'fs';
3
3
  import path from 'path';
4
4
  import { ApplicationCommandType, Routes } from 'discord.js';
5
5
  import CliTable3 from 'cli-table3';
@@ -103,19 +103,35 @@ const scopeKey = (scope)=>scope === 'global' ? 'global' : `guild-${scope.guild}`
103
103
  const guilds = options.config?.guilds ?? [];
104
104
  return !everythingTarget(options) && guilds.length > 0 && ids(guilds).length === 0;
105
105
  }
106
+ /** Whether a command is an activity's primary entry point, which Discord takes only globally. */ const isEntryPoint = (command)=>command.body.type === ApplicationCommandType.PrimaryEntryPoint;
107
+ /** What a blank guild to register every command to says, as an unset variable gives one. */ const BLANK_GUILD = 'No commands were registered: the guild to register to is blank. Give a guild id, or leave it out to register where commands.* says.';
106
108
  /**
107
109
  * Where each command is sent: one bulk update per scope. A scope's update replaces everything the application has
108
110
  * there, so the default scope is included even when it ends up empty, unless `commands.guilds` lists no guild id.
111
+ *
112
+ * A primary entry point command goes in the global update, as Discord refuses a guild update that holds one. A run
113
+ * that sends everything to one guild sends nothing global, so it leaves the entry point out.
109
114
  */ function planTargets(commands, options, logger) {
115
+ // Registering to the default scopes instead could publish everywhere
116
+ if (options.onlyGuild !== undefined && !options.onlyGuild.trim()) {
117
+ logger.error(BLANK_GUILD);
118
+ return [];
119
+ }
110
120
  const everythingTo = everythingTarget(options);
111
- if (everythingTo) return [
112
- {
113
- scope: {
114
- guild: everythingTo
115
- },
116
- commands
121
+ if (everythingTo) {
122
+ // A global update from here would hold only what this run sends, deleting the application's other global commands
123
+ for (const command of commands.filter(isEntryPoint)){
124
+ logger.warn(`Entry point command "${command.name}" is registered globally only; a production run or \`meocord register\` registers it.`);
117
125
  }
118
- ];
126
+ return [
127
+ {
128
+ scope: {
129
+ guild: everythingTo
130
+ },
131
+ commands: commands.filter((command)=>!isEntryPoint(command))
132
+ }
133
+ ];
134
+ }
119
135
  const defaultGuilds = ids(options.config?.guilds);
120
136
  const nowhere = defaultGuildsEmpty(options);
121
137
  const targets = new Map();
@@ -133,7 +149,12 @@ const scopeKey = (scope)=>scope === 'global' ? 'global' : `guild-${scope.guild}`
133
149
  });
134
150
  const unregistered = [];
135
151
  for (const command of commands){
136
- if (command.guilds) {
152
+ if (isEntryPoint(command)) {
153
+ if (command.guilds) {
154
+ logger.warn(`Entry point command "${command.name}" can only be registered globally, so the guilds its builder lists are ignored.`);
155
+ }
156
+ add('global', command);
157
+ } else if (command.guilds) {
137
158
  const own = ids(command.guilds);
138
159
  // Falling back to the default scope could publish a staff-only command everywhere.
139
160
  if (own.length === 0) logger.warn(`Command "${command.name}" lists no guild ids in its builder, so it is not registered.`);
@@ -151,7 +172,7 @@ const scopeKey = (scope)=>scope === 'global' ? 'global' : `guild-${scope.guild}`
151
172
  }
152
173
  }
153
174
  if (nowhere) {
154
- const outcome = unregistered.length === 0 ? 'nothing is registered globally' : `${unregistered.join(', ')} ${unregistered.length === 1 ? 'is' : 'are'} not registered, rather than registered globally`;
175
+ const outcome = unregistered.length === 0 ? targets.has('global') ? 'only the entry point command is registered globally' : 'nothing is registered globally' : `${unregistered.join(', ')} ${unregistered.length === 1 ? 'is' : 'are'} not registered, rather than registered globally`;
155
176
  logger.warn(`commands.guilds lists no guild id, as an unset environment variable leaves it, so ${outcome}. ` + `Set the guild ids, or remove commands.guilds to register globally.`);
156
177
  }
157
178
  return [
@@ -180,28 +201,57 @@ const describeScope = (scope)=>scope === 'global' ? 'globally' : `to guild ${sco
180
201
  return path.join(nodeModules, '.cache', 'meocord', `commands-${applicationId}-${scopeKey(scope)}.json`);
181
202
  }
182
203
  const hashOf = (commands)=>createHash('sha256').update(JSON.stringify(commands.map(({ body })=>body))).digest('hex');
183
- function readHash(file, logger) {
204
+ function readRecord(file, logger) {
184
205
  if (!file || !existsSync(file)) return undefined;
185
206
  try {
186
- return JSON.parse(readFileSync(file, 'utf8')).hash;
207
+ return JSON.parse(readFileSync(file, 'utf8'));
187
208
  } catch (error) {
188
209
  logger.debug(`Could not read the registered commands, so they are sent again: ${String(error)}`);
189
210
  return undefined;
190
211
  }
191
212
  }
192
- function writeHash(file, hash, logger) {
213
+ function writeRecord(file, record, logger) {
193
214
  if (!file) return;
194
215
  try {
195
216
  mkdirSync(path.dirname(file), {
196
217
  recursive: true
197
218
  });
198
- writeFileSync(file, `${JSON.stringify({
199
- hash
200
- })}\n`);
219
+ writeFileSync(file, `${JSON.stringify(record)}\n`);
201
220
  } catch (error) {
202
221
  logger.debug(`Could not record the registered commands: ${String(error)}`);
203
222
  }
204
223
  }
224
+ /** Forgets what development last sent to a scope, once that scope is written some other way. */ function forgetRecord(file, logger) {
225
+ if (!file || !existsSync(file)) return;
226
+ try {
227
+ rmSync(file);
228
+ } catch (error) {
229
+ logger.debug(`Could not forget the registered commands: ${String(error)}`);
230
+ }
231
+ }
232
+ /**
233
+ * Each command's `id:version`, sorted, from what Discord lists or answers a bulk update with; `undefined` for anything
234
+ * else. Discord gives a command a new version whenever it is updated, so the set changes with any edit, removal or
235
+ * re-registration, from this checkout or any other.
236
+ */ function versionsOf(commands) {
237
+ if (!Array.isArray(commands)) return undefined;
238
+ const versions = commands.map((command)=>{
239
+ const { id, version } = command ?? {};
240
+ return typeof id === 'string' && typeof version === 'string' ? `${id}:${version}` : undefined;
241
+ });
242
+ return versions.every((version)=>version !== undefined) ? versions.sort() : undefined;
243
+ }
244
+ /** Whether Discord still holds a scope as development last sent it, by each command's version; a failed listing doesn't. */ async function stillAsSent(rest, route, record, scope, logger) {
245
+ if (!record.versions) return false;
246
+ let registered;
247
+ try {
248
+ registered = await rest.get(route);
249
+ } catch (error) {
250
+ logger.debug(`Could not list the commands registered ${describeScope(scope)}, so they are sent again: ${String(error)}`);
251
+ return false;
252
+ }
253
+ return versionsOf(registered)?.join('\n') === record.versions.join('\n');
254
+ }
205
255
  function logRegistered(target, logger) {
206
256
  const table = new CliTable3({
207
257
  head: [
@@ -224,22 +274,29 @@ function logRegistered(target, logger) {
224
274
  };
225
275
  for (const { name, body } of target.commands){
226
276
  const options = Array.isArray(body.options) ? body.options : [];
277
+ // A subcommand or a subcommand group, not a plain option
278
+ const subcommands = options.filter((option)=>option.type === 1 || option.type === 2);
227
279
  table.push([
228
280
  name,
229
281
  typeNames[typeof body.type === 'number' ? body.type : 1] ?? 'Command',
230
- options.map((option)=>option.name).join(', ')
282
+ subcommands.map((option)=>option.name).join(', ')
231
283
  ]);
232
284
  }
233
285
  logger.log(`Registered ${target.commands.length} bot commands ${describeScope(target.scope)}:\n${table.toString()}`);
234
286
  }
235
287
  /**
236
- * Registers the commands, one bulk update per scope; development skips a scope unchanged since its last send, unless
237
- * `force`. Once one is sent, and not to `onlyGuild`, the other scopes the configuration names are checked for
288
+ * Registers the commands, one bulk update per scope; development skips a scope unchanged since its last send and still
289
+ * registered as sent, which one listing per skipped scope checks by each command's version, unless `force`. A blank `onlyGuild` registers nothing. Once one is sent, and not to `onlyGuild`, the other scopes the configuration names are checked for
238
290
  * leftovers, which `clearOther` removes; a development-guild run or a `commands.guilds` with no id only warns. Never
239
291
  * throws.
240
292
  * @returns Whether every scope was sent or unchanged, and `commands.guilds`, if set, lists a guild id.
241
293
  */ async function registerCommands(options) {
242
294
  const { rest, applicationId, controllerClasses, logger, config, development, onlyGuild, force = false } = options;
295
+ // An unset variable passed as the guild; registering to the default scopes instead could publish everywhere
296
+ if (onlyGuild !== undefined && !onlyGuild.trim()) {
297
+ logger.error(BLANK_GUILD);
298
+ return false;
299
+ }
243
300
  const commands = collectCommands(controllerClasses, logger);
244
301
  if (!commands) return false;
245
302
  const targets = planTargets(commands, {
@@ -256,18 +313,26 @@ function logRegistered(target, logger) {
256
313
  let succeeded = !guildsEmpty;
257
314
  let sent = false;
258
315
  for (const target of targets){
259
- const file = development ? cacheFile(applicationId, target.scope) : undefined;
316
+ const file = cacheFile(applicationId, target.scope);
260
317
  const hash = hashOf(target.commands);
261
- if (development && !force && readHash(file, logger) === hash) {
318
+ const route = routeFor(applicationId, target.scope);
319
+ // Unchanged since development last sent it, and still what Discord holds there
320
+ const record = development && !force ? readRecord(file, logger) : undefined;
321
+ if (record?.hash === hash && await stillAsSent(rest, route, record, target.scope, logger)) {
262
322
  logger.log(`Commands ${describeScope(target.scope)} are unchanged; not registering them again.`);
263
323
  continue;
264
324
  }
265
325
  try {
266
- await rest.put(routeFor(applicationId, target.scope), {
326
+ const answered = await rest.put(route, {
267
327
  body: target.commands.map(({ body })=>body)
268
328
  });
269
329
  sent = true;
270
- if (development) writeHash(file, hash, logger);
330
+ // Outside development the record would be stale, so the next development start sends again
331
+ if (development) writeRecord(file, {
332
+ hash,
333
+ versions: versionsOf(answered)
334
+ }, logger);
335
+ else forgetRecord(file, logger);
271
336
  logRegistered(target, logger);
272
337
  } catch (error) {
273
338
  succeeded = false;
@@ -296,13 +361,17 @@ function logRegistered(target, logger) {
296
361
  if (!Array.isArray(existing) || existing.length === 0) continue;
297
362
  const names = existing.map(({ name })=>name).join(', ');
298
363
  if (!config?.clearOther || keptBecause) {
299
- logger.warn(`${existing.length} command(s) are still registered ${describeScope(scope)} (${names}), which this ` + `configuration does not register to, so Discord keeps showing them there. ` + (config?.clearOther ? `clearOther is on, but they are not removed ${keptBecause}.` : 'Set commands.clearOther to remove them.'));
364
+ // Advising clearOther when it would not apply sends the reader to a setting that changes nothing
365
+ const advice = !keptBecause ? 'Set commands.clearOther to remove them.' : config?.clearOther ? `clearOther is on, but they are not removed ${keptBecause}.` : `They are not removed ${keptBecause}, even with commands.clearOther.`;
366
+ logger.warn(`${existing.length} command(s) are still registered ${describeScope(scope)} (${names}), which this ` + `configuration does not register to, so Discord keeps showing them there. ${advice}`);
300
367
  continue;
301
368
  }
302
369
  try {
303
370
  await rest.put(routeFor(applicationId, scope), {
304
371
  body: []
305
372
  });
373
+ // What development last sent there is gone, so its next start sends again
374
+ forgetRecord(cacheFile(applicationId, scope), logger);
306
375
  logger.log(`Removed ${existing.length} command(s) left registered ${describeScope(scope)}: ${names}`);
307
376
  } catch (error) {
308
377
  logger.error(`Error removing the commands left registered ${describeScope(scope)}:`, error);
@@ -1,4 +1,4 @@
1
- import { getCommandMap } from '../decorator/controller.decorator.js';
1
+ import { commandsNamed, getCommandMap } from '../decorator/controller.decorator.js';
2
2
  import { resolveCommandPaths, matchesHandler } from '../util/interaction.util.js';
3
3
 
4
4
  /** The keys a command is routed by, most specific first: a chat command's full path, then its name; none for anything else. */ function commandRouteKeys(interaction) {
@@ -15,7 +15,7 @@ import { resolveCommandPaths, matchesHandler } from '../util/interaction.util.js
15
15
  */ function matchCommandRoute(controllers, interaction) {
16
16
  for (const key of commandRouteKeys(interaction)){
17
17
  for (const controllerClass of controllers){
18
- const meta = getCommandMap(controllerClass.prototype)?.[key]?.find((each)=>matchesHandler(each, interaction));
18
+ const meta = commandsNamed(getCommandMap(controllerClass.prototype), key).find((each)=>matchesHandler(each, interaction));
19
19
  if (meta) return {
20
20
  controllerClass,
21
21
  meta
@@ -1,10 +1,36 @@
1
- import { getCommandMap, patternShape, findAmbiguousRoutes, createRegexFromPattern } from '../decorator/controller.decorator.js';
1
+ import { getCommandMap, patternShape, paramNarrowness, sharedCustomId, createRegexFromPattern } from '../decorator/controller.decorator.js';
2
2
  import { decodeRouteParams } from '../common/route.js';
3
3
  import { parseSegment } from './scalar-types.js';
4
4
  import { refuse } from '../util/refusal.util.js';
5
5
 
6
6
  /**
7
- * Every customId-pattern route of the given controllers, most specific first, read from metadata alone.
7
+ * How a pattern ranks, one character per segment twice over: `L` for literal text or `P` for a param, then how narrow
8
+ * each param is, the narrowest as the lowest digit. Two keys of one length compare as text: literal before param at
9
+ * the first segment where they differ, then the narrower type.
10
+ */ function rankKey(pattern) {
11
+ const narrowness = pattern.split('/').map(paramNarrowness);
12
+ const kinds = narrowness.map((rank)=>rank === undefined ? 'L' : 'P').join('');
13
+ return `${kinds}/${narrowness.map((rank)=>String(rank === undefined ? 0 : 9 - rank)).join('')}`;
14
+ }
15
+ /**
16
+ * Orders two routes as dispatch tries them. Left to right, the first segment one pattern spells out as literal text
17
+ * while the other leaves it to a param ranks the literal one first: `a/{x}` before `{x}/abcd`, `profile/me/{section}`
18
+ * before `profile/{userId}/edit`. Between patterns that leaves tied, the first param where one type is narrower ranks
19
+ * it first: words to choose from, then `bool`, `uuid`, `snowflake`, `int`, `number`, then text. Patterns of different
20
+ * segment counts, which no customId matches both of, are ordered by count only so the order is total; component
21
+ * patterns have no catch-all param. What remains tied keeps its listing order.
22
+ */ function rank(keys) {
23
+ return (a, b)=>{
24
+ const [left, right] = [
25
+ keys.get(a),
26
+ keys.get(b)
27
+ ];
28
+ if (left.length !== right.length) return left.length - right.length;
29
+ return left < right ? -1 : left > right ? 1 : 0;
30
+ };
31
+ }
32
+ /**
33
+ * Every customId-pattern route of the given controllers, in the order dispatch tries them, read from metadata alone.
8
34
  * Throws for two handlers whose patterns match the same customIds of one component type; one handler
9
35
  * declared under two spellings of a pattern keeps one route.
10
36
  */ function buildComponentRoutes(controllerClasses) {
@@ -37,22 +63,11 @@ import { refuse } from '../util/refusal.util.js';
37
63
  }
38
64
  }
39
65
  }
40
- // A stable sort, so equally specific patterns keep the order their controllers and handlers are listed in
41
- return routes.sort((a, b)=>(b.meta.specificity ?? 0) - (a.meta.specificity ?? 0));
42
- }
43
- /**
44
- * Whether `a` spells out the first segment where it and `b` differ as literal text while `b` leaves it to a
45
- * parameter, the tie-break between equally specific patterns in the next major version (5.0).
46
- */ function literalFirst(a, b) {
47
- const [left, right] = [
48
- a.split('/'),
49
- b.split('/')
50
- ];
51
- for(let i = 0; i < Math.min(left.length, right.length); i++){
52
- const leftLiteral = !left[i].includes('{');
53
- if (leftLiteral !== !right[i].includes('{')) return leftLiteral;
54
- }
55
- return false;
66
+ // A stable sort, so what the ranking leaves tied keeps the order controllers and handlers are listed in
67
+ return routes.sort(rank(new Map(routes.map((route)=>[
68
+ route,
69
+ rankKey(route.pattern)
70
+ ]))));
56
71
  }
57
72
  /** A component type as an error names it: `button`, `modal submit`, `select menu`. */ const typeLabel = (type)=>type.toLowerCase().replaceAll('_', ' ');
58
73
  /**
@@ -77,6 +92,12 @@ const typesByPattern = new Map();
77
92
  if (!types) typesByPattern.set(pattern, types = createRegexFromPattern(pattern).types);
78
93
  return types;
79
94
  }
95
+ /**
96
+ * The params `pattern`, compiled as `regex`, captures from a customId, each typed param as its value:
97
+ * `undefined` when the customId does not match, or a segment is not a value of its param's type.
98
+ */ function readCustomId(pattern, regex, customId) {
99
+ return readCustomIdSegments(pattern, regex, customId)?.values;
100
+ }
80
101
  /** {@link readCustomId}'s values, with each param's text as the customId gave it. */ function readCustomIdSegments(pattern, regex, customId) {
81
102
  const match = regex.exec(customId);
82
103
  if (!match) return undefined;
@@ -94,18 +115,40 @@ const typesByPattern = new Map();
94
115
  text
95
116
  };
96
117
  }
97
- /** Pattern pairs that can match one customId, compared only within a component type, as dispatch does. */ function findComponentRouteConflicts(routes) {
98
- const byType = new Map();
99
- for (const { meta, pattern } of routes)byType.set(meta.type, [
100
- ...byType.get(meta.type) ?? [],
101
- pattern
102
- ]);
103
- return [
104
- ...byType
105
- ].flatMap(([type, patterns])=>findAmbiguousRoutes(patterns).map((pair)=>({
106
- type,
107
- patterns: pair
108
- })));
118
+ /**
119
+ * Pattern pairs of one component type that the ranking leaves tied and that can match one customId, each with the
120
+ * pattern that runs, the one ranked first in `routes`, and an id both match. Only patterns of one rank key, read
121
+ * together, can tie, so each group is compared within itself.
122
+ */ function findComponentRouteConflicts(routes) {
123
+ const groups = new Map();
124
+ for (const route of routes){
125
+ // The literal text and each param's narrowness: patterns that differ in either rank apart or match no id in common
126
+ const key = `${route.meta.type}\0${route.pattern.split('/').map((segment)=>paramNarrowness(segment) ?? `=${segment}`).join('\0')}`;
127
+ groups.set(key, [
128
+ ...groups.get(key) ?? [],
129
+ route
130
+ ]);
131
+ }
132
+ const conflicts = [];
133
+ for (const group of groups.values()){
134
+ for(let i = 0; i < group.length; i++){
135
+ for(let j = i + 1; j < group.length; j++){
136
+ const customId = sharedCustomId(group[i].pattern, group[j].pattern);
137
+ if (customId === undefined) continue;
138
+ const patterns = [
139
+ group[i].pattern,
140
+ group[j].pattern
141
+ ];
142
+ conflicts.push({
143
+ type: group[i].meta.type,
144
+ patterns,
145
+ runs: group[i].pattern,
146
+ customId
147
+ });
148
+ }
149
+ }
150
+ }
151
+ return conflicts;
109
152
  }
110
153
 
111
- export { buildComponentRoutes, findComponentRouteConflicts, literalFirst, matchComponentRoute };
154
+ export { buildComponentRoutes, findComponentRouteConflicts, matchComponentRoute, readCustomId };