meocord 4.1.1 → 4.2.0

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 (96) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/README.md +23 -23
  3. package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-CRn9kWob.cjs} +295 -87
  4. package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-ChvMqnLd.cjs} +74 -40
  5. package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-BdAUTdYs.cjs} +1 -1
  6. package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CQo8mSP8.cjs} +158 -72
  7. package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-DhDVbT5M.cjs} +12 -3
  8. package/dist/cjs/common/index.cjs +4 -4
  9. package/dist/cjs/core/index.cjs +195 -157
  10. package/dist/cjs/decorator/index.cjs +66 -57
  11. package/dist/cjs/testing/index.cjs +736 -307
  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/build/load-config.pre-entry.js +4 -1
  21. package/dist/esm/common/decorator.js +6 -6
  22. package/dist/esm/common/deprecation.js +2 -2
  23. package/dist/esm/common/errors.js +2 -2
  24. package/dist/esm/common/execution-context.js +1 -1
  25. package/dist/esm/common/metadata.js +1 -1
  26. package/dist/esm/common/route.js +10 -1
  27. package/dist/esm/common/translate-error.js +1 -1
  28. package/dist/esm/core/command-conflicts.js +34 -34
  29. package/dist/esm/core/component-routes.js +74 -31
  30. package/dist/esm/core/dispatcher.js +51 -7
  31. package/dist/esm/core/filter-runner.js +2 -2
  32. package/dist/esm/core/handler-pipeline.js +27 -14
  33. package/dist/esm/core/handler-registry.js +1 -1
  34. package/dist/esm/core/interceptor-runner.js +2 -2
  35. package/dist/esm/core/meocord-factory.js +196 -157
  36. package/dist/esm/core/message-routes.js +4 -3
  37. package/dist/esm/core/observer-runner.js +4 -3
  38. package/dist/esm/core/providers.js +38 -21
  39. package/dist/esm/core/route-specificity.js +4 -4
  40. package/dist/esm/core/scalar-types.js +15 -3
  41. package/dist/esm/core/shard-context.js +1 -1
  42. package/dist/esm/core/startup-roots.js +2 -3
  43. package/dist/esm/core/theme-runtime.js +2 -2
  44. package/dist/esm/decorator/app.decorator.js +12 -7
  45. package/dist/esm/decorator/command-builder.decorator.js +4 -4
  46. package/dist/esm/decorator/controller-class.decorator.js +9 -4
  47. package/dist/esm/decorator/controller.decorator.js +110 -67
  48. package/dist/esm/decorator/cooldown.decorator.js +3 -3
  49. package/dist/esm/decorator/defer.decorator.js +4 -4
  50. package/dist/esm/decorator/event.decorator.js +5 -5
  51. package/dist/esm/decorator/filter.decorator.js +7 -7
  52. package/dist/esm/decorator/guard.decorator.js +8 -8
  53. package/dist/esm/decorator/inject.decorator.js +4 -4
  54. package/dist/esm/decorator/interceptor.decorator.js +7 -7
  55. package/dist/esm/decorator/observer.decorator.js +4 -4
  56. package/dist/esm/decorator/service.decorator.js +4 -4
  57. package/dist/esm/decorator/theme.decorator.js +3 -3
  58. package/dist/esm/decorator/validation.decorator.js +10 -10
  59. package/dist/esm/package.json.js +1 -1
  60. package/dist/esm/testing/catalog.js +1 -1
  61. package/dist/esm/testing/cooldown-store-suite.js +18 -10
  62. package/dist/esm/testing/discord-defaults.js +60 -7
  63. package/dist/esm/testing/index.js +4 -2
  64. package/dist/esm/testing/inspect-handler.js +20 -3
  65. package/dist/esm/testing/meocord-testing-module.js +229 -188
  66. package/dist/esm/testing/mock-fn.js +80 -10
  67. package/dist/esm/testing/mock-interaction.js +226 -78
  68. package/dist/esm/testing/response.js +1 -1
  69. package/dist/esm/testing/routing.js +39 -14
  70. package/dist/esm/testing/startup-errors.js +26 -0
  71. package/dist/esm/testing/strict-mocks.js +45 -0
  72. package/dist/esm/testing/theme.js +1 -1
  73. package/dist/esm/util/bundle-entry.util.js +2 -1
  74. package/dist/esm/util/interaction.util.js +1 -1
  75. package/dist/esm/util/meocord-config-validation.util.js +1 -0
  76. package/dist/esm/util/metadata-keys.js +1 -0
  77. package/dist/esm/util/refusal.util.js +148 -8
  78. package/dist/types/common/index.d.cts +8 -8
  79. package/dist/types/common/index.d.ts +8 -8
  80. package/dist/types/core/index.d.cts +8 -6
  81. package/dist/types/core/index.d.ts +8 -6
  82. package/dist/types/decorator/index.d.cts +30 -29
  83. package/dist/types/decorator/index.d.ts +30 -29
  84. package/dist/types/{index-D7n0FBMm.d.ts → index-CdXsFgjE.d.ts} +62 -15
  85. package/dist/types/{index-DEP0NYrU.d.cts → index-nn5p7OoX.d.cts} +62 -15
  86. package/dist/types/interface/index.d.cts +1 -1
  87. package/dist/types/interface/index.d.ts +1 -1
  88. package/dist/types/{route-C-aMI7Sl.d.cts → route-BZgFoNeB.d.cts} +5 -2
  89. package/dist/types/{route-C-aMI7Sl.d.ts → route-BZgFoNeB.d.ts} +5 -2
  90. package/dist/types/testing/index.d.cts +245 -31
  91. package/dist/types/testing/index.d.ts +245 -31
  92. package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-BcOJVzR9.d.ts} +1 -1
  93. package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-UKr3-6Ys.d.cts} +1 -1
  94. package/dist/types/{translator-J6tIUfdp.d.ts → translator-BViWKtLJ.d.ts} +1 -1
  95. package/dist/types/{translator-B4mc1lzJ.d.cts → translator-DHMkkCpg.d.cts} +1 -1
  96. package/package.json +3 -2
package/dist/cli.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "meocordVersion": "4.1.1",
3
+ "meocordVersion": "4.2.0",
4
4
  "name": "meocord",
5
5
  "description": "CLI for managing the MeoCord application",
6
6
  "usage": "[options] [command]",
@@ -94,7 +94,9 @@ interceptors, filters })`. An observer hears about every call once listed in
94
94
  `meocord/testing` provides `MeoCordTestingModule` for building a controller with its
95
95
  dependencies, `invoke` for running a handler through its guards, interceptors and
96
96
  filters as the bot does, and `createMockInteraction` and friends for driving it without
97
- a Discord connection. `vitest.setup.ts` resets those mocks after every test, so set
97
+ a Discord connection. `vitest.setup.ts` has those mocks made with `vi.fn`, so Vitest
98
+ treats them as its own, and has them compute what discord.js computes, such as a
99
+ message's `editable`, rather than placeholders. It resets them after every test, so set
98
100
  what a mock returns in the test, or in `beforeEach`, that relies on it.
99
101
 
100
102
  ## Linting
@@ -9,6 +9,10 @@ import {
9
9
  } from 'meocord/testing'
10
10
  import { SampleButtonController } from '@src/controllers/button/sample.button.controller.js'
11
11
 
12
+ // Discord user ids, as the customId and the guard compare them
13
+ const owner = '200000000000000001'
14
+ const stranger = '200000000000000002'
15
+
12
16
  const clickBy = (userId: string, customId = 'button-click') => {
13
17
  const user = createMockUser()
14
18
  user.id = userId
@@ -19,7 +23,7 @@ describe('SampleButtonController', () => {
19
23
  const module = MeoCordTestingModule.create({ controllers: [SampleButtonController] }).compile()
20
24
 
21
25
  it('locks the message while it works, then answers', async () => {
22
- const interaction = clickBy('someone')
26
+ const interaction = clickBy(stranger)
23
27
 
24
28
  await module.invoke(SampleButtonController, 'handleButton', interaction)
25
29
 
@@ -29,16 +33,16 @@ describe('SampleButtonController', () => {
29
33
  })
30
34
 
31
35
  it('answers the owner of the button', async () => {
32
- const interaction = clickBy('owner', 'button-with/owner')
36
+ const interaction = clickBy(owner, `button-with/${owner}`)
33
37
 
34
38
  // The customId's route gives the handler its ownerId
35
39
  await module.invoke(SampleButtonController, 'handleButtonWithId', interaction)
36
40
 
37
- expect(getResponse(interaction).calls.at(-1)?.payload).toMatchObject({ content: 'Button of <@owner> clicked!' })
41
+ expect(getResponse(interaction).calls.at(-1)?.payload).toMatchObject({ content: `Button of <@${owner}> clicked!` })
38
42
  })
39
43
 
40
44
  it('denies anyone else before touching the message', async () => {
41
- const interaction = clickBy('stranger', 'button-with/owner')
45
+ const interaction = clickBy(stranger, `button-with/${owner}`)
42
46
 
43
47
  await expect(module.invoke(SampleButtonController, 'handleButtonWithId', interaction)).rejects.toThrow(
44
48
  GuardDeniedError,
@@ -15,8 +15,8 @@ export class SampleButtonController {
15
15
  await respond(interaction).send({ content: 'Button clicked!' })
16
16
  }
17
17
 
18
- // Only the user whose id the customId carries gets past OwnerGuard
19
- @Command('button-with/{ownerId}', CommandType.BUTTON)
18
+ // Only the user whose id the customId carries gets past OwnerGuard; `:snowflake` takes only a Discord id
19
+ @Command('button-with/{ownerId:snowflake}', CommandType.BUTTON)
20
20
  @UseGuard(OwnerGuard)
21
21
  @Cooldown({ uses: 5, seconds: 60 })
22
22
  @Defer()
@@ -7,30 +7,34 @@ import { OwnerGuard } from '@src/guards/owner.guard.js'
7
7
 
8
8
  @Controller()
9
9
  class CardController {
10
- @Command('card/{ownerId}', CommandType.BUTTON)
10
+ @Command('card/{ownerId:snowflake}', CommandType.BUTTON)
11
11
  @UseGuard(OwnerGuard)
12
12
  async open(interaction: ButtonInteraction) {
13
13
  await interaction.deferUpdate()
14
14
  }
15
15
  }
16
16
 
17
+ // Discord user ids, as the customId and the guard compare them
18
+ const owner = '200000000000000001'
19
+ const stranger = '200000000000000002'
20
+
17
21
  const clickBy = (userId: string) => {
18
22
  const user = createMockUser()
19
23
  user.id = userId
20
24
  // invoke reads ownerId from the customId, as dispatch does
21
- return createMockInteraction(ButtonInteraction, { customId: 'card/owner', user })
25
+ return createMockInteraction(ButtonInteraction, { customId: `card/${owner}`, user })
22
26
  }
23
27
 
24
28
  describe('OwnerGuard', () => {
25
29
  const module = MeoCordTestingModule.create({ controllers: [CardController] }).compile()
26
30
 
27
31
  it("lets the button's owner through", async () => {
28
- const { ran } = await module.invoke(CardController, 'open', clickBy('owner'))
32
+ const { ran } = await module.invoke(CardController, 'open', clickBy(owner))
29
33
 
30
34
  expect(ran).toBe(true)
31
35
  })
32
36
 
33
37
  it('denies anyone else with a GuardDeniedError', async () => {
34
- await expect(module.invoke(CardController, 'open', clickBy('stranger'))).rejects.toThrow(GuardDeniedError)
38
+ await expect(module.invoke(CardController, 'open', clickBy(stranger))).rejects.toThrow(GuardDeniedError)
35
39
  })
36
40
  })
@@ -3,7 +3,7 @@ import { Guard } from 'meocord/decorator'
3
3
  import { GuardDeniedError } from 'meocord/common'
4
4
  import { type GuardInterface } from 'meocord/interface'
5
5
 
6
- /** Lets only the user whose id a button carries use it: `@Command('card/{ownerId}', ...)` */
6
+ /** Lets only the user whose id a button carries use it: `@Command('card/{ownerId:snowflake}', ...)` */
7
7
  @Guard()
8
8
  export class OwnerGuard implements GuardInterface {
9
9
  canActivate(interaction: ButtonInteraction, { ownerId }: { ownerId: string }): boolean {
@@ -24,7 +24,7 @@ export default defineConfig({
24
24
  test: {
25
25
  globals: true,
26
26
  setupFiles: ['./vitest.setup.ts'],
27
- // vi.fn() mocks only; vitest.setup.ts resets the mocks from meocord/testing
27
+ // Every mock's calls, meocord/testing's included; vitest.setup.ts also resets meocord's to how each was created
28
28
  clearMocks: true,
29
29
  include: ['src/**/*.spec.ts'],
30
30
  coverage: {
@@ -1,6 +1,10 @@
1
1
  import 'reflect-metadata'
2
- import { resetAllMocks } from 'meocord/testing'
3
- import { afterEach } from 'vitest'
2
+ import { resetAllMocks, useMockFn, useStrictMocks } from 'meocord/testing'
3
+ import { afterEach, vi } from 'vitest'
4
4
 
5
- // Every test starts from mocks as they were created: clearMocks in vitest.config.ts reaches only vi.fn()
5
+ // meocord/testing makes its mocks with vi.fn, so Vitest's matchers, config and vi.mocked() treat them as its own
6
+ useMockFn(vi.fn)
7
+ // Mocks compute what discord.js computes, such as a message's editable or a member's kickable, rather than placeholders
8
+ useStrictMocks()
9
+ // Every test starts from meocord's mocks as they were created
6
10
  afterEach(() => resetAllMocks())
@@ -5,7 +5,7 @@ import path from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
6
  import { installStackRemapper } from './stack-remap.js'
7
7
  import { listenForDevRunnerStop } from '../util/dev-runner.util.js'
8
- import { BUILD_MODE_KEY, BUNDLE_ENTRY_KEY } from '../util/bundle-entry.util.js'
8
+ import { BUILD_MODE_KEY, BUNDLE_ENTRY_KEY, STARTUP_ERRORS_KEY } from '../util/bundle-entry.util.js'
9
9
  import { loadMeoCordConfig } from '../util/meocord-config-loader.util.js'
10
10
 
11
11
  // First, so a restart `meocord start --dev` asks for while the bundle still loads is heard
@@ -26,4 +26,7 @@ __webpack_public_path__ = `${path.dirname(bundle).replace(/\\/g, '/')}/`
26
26
  // bundled, else a copy of it, and require's cache evaluates the config once either way; the factory reports a failure
27
27
  const config = loadMeoCordConfig()
28
28
 
29
+ // Before the entry's decorators run, so with 'all' they keep their startup errors for create() to report together
30
+ if (config?.startupErrors === 'all') globalThis[STARTUP_ERRORS_KEY] = 'all'
31
+
29
32
  if (config?.sourceMappedStacks !== false) installStackRemapper(bundle)
@@ -1,7 +1,7 @@
1
1
  import { warnDeprecated } from './deprecation.js';
2
2
  import { Logger } from './logger.js';
3
3
  import { MetadataKey } from '../enum/metadata-key.enum.js';
4
- import { refuse, decoratedName } from '../util/refusal.util.js';
4
+ import { declaring, refuse, decoratedName } from '../util/refusal.util.js';
5
5
 
6
6
  /**
7
7
  * Composes several class or method decorators into one.
@@ -30,9 +30,9 @@ import { refuse, decoratedName } from '../util/refusal.util.js';
30
30
  *
31
31
  * @group Utilities
32
32
  * @see {@link createMetadata}
33
- * @see {@link https://meocord.dev/docs/4.1/custom-decorators | Custom decorators}
33
+ * @see {@link https://meocord.dev/docs/4.2/custom-decorators | Custom decorators}
34
34
  */ function applyDecorators(...decorators) {
35
- return function(target, propertyKey, descriptor) {
35
+ return declaring(function(target, propertyKey, descriptor) {
36
36
  // Last first, as TypeScript applies decorators stacked, each given what the one before it returned
37
37
  const ordered = [
38
38
  ...decorators
@@ -45,7 +45,7 @@ import { refuse, decoratedName } from '../util/refusal.util.js';
45
45
  let current = target;
46
46
  for (const decorator of ordered)current = decorator(current) ?? current;
47
47
  return current;
48
- };
48
+ });
49
49
  }
50
50
  const logger = new Logger('SetMetadata');
51
51
  /** The prefix of every key MeoCord keeps its own metadata under. */ const MEOCORD_PREFIX = 'meocord:';
@@ -84,7 +84,7 @@ const logger = new Logger('SetMetadata');
84
84
  * @see {@link createMetadata}
85
85
  */ function SetMetadata(metadataKey, metadataValue) {
86
86
  warnDeprecated(logger, 'SetMetadata', 'createMetadata');
87
- return function(target, propertyKey) {
87
+ return declaring(function(target, propertyKey) {
88
88
  // Checked where it applies, so the refusal names the handler or controller
89
89
  const owner = metadataKey.startsWith(MEOCORD_PREFIX) ? `MeoCord keeps its own metadata under keys beginning "${MEOCORD_PREFIX}"` : INJECTION_KEYS.has(metadataKey) ? 'dependency injection reads how to make the class from it' : undefined;
90
90
  if (owner) {
@@ -95,7 +95,7 @@ const logger = new Logger('SetMetadata');
95
95
  } else {
96
96
  Reflect.defineMetadata(metadataKey, metadataValue, target);
97
97
  }
98
- };
98
+ });
99
99
  }
100
100
 
101
101
  export { SetMetadata, applyDecorators };
@@ -14,10 +14,10 @@ const warned = new Set();
14
14
  */ function warnPlaceholder(logger, value, outcome, how) {
15
15
  warnOnce(logger, `${value} reads a placeholder here; in the next major version (5.0) ${outcome}. ${how}`);
16
16
  }
17
- function warnOnce(logger, text) {
17
+ /** Logs `text` once per run, however often it is asked for. */ function warnOnce(logger, text) {
18
18
  if (warned.has(text)) return;
19
19
  warned.add(text);
20
20
  logger.warn(text);
21
21
  }
22
22
 
23
- export { warnDeprecated, warnDeprecatedBehaviour, warnPlaceholder };
23
+ export { warnDeprecated, warnDeprecatedBehaviour, warnOnce, warnPlaceholder };
@@ -90,7 +90,7 @@ import { renderText } from './meocord-text.js';
90
90
  * @group Responses
91
91
  * @category Errors
92
92
  * @see {@link Catch}
93
- * @see {@link https://meocord.dev/docs/4.1/exception-filters | Exception filters}
93
+ * @see {@link https://meocord.dev/docs/4.2/exception-filters | Exception filters}
94
94
  */ class CommandNotFoundError extends Error {
95
95
  /** @param message - What the error says, `No handler matched the interaction.` unless given. */ constructor(message = 'No handler matched the interaction.'){
96
96
  super(message);
@@ -163,7 +163,7 @@ import { renderText } from './meocord-text.js';
163
163
  * @category Errors
164
164
  * @see {@link MessageHandler}
165
165
  * @see {@link translateError}
166
- * @see {@link https://meocord.dev/docs/4.1/message-commands | Message commands}
166
+ * @see {@link https://meocord.dev/docs/4.2/message-commands | Message commands}
167
167
  */ class MessageUsageError extends Error {
168
168
  /**
169
169
  * @param usage - The command as the user should type it, such as `!ban <target> [reason…]`, or one line per
@@ -33,7 +33,7 @@ import { useTheme } from '../core/theme-scope.js';
33
33
  * @group Utilities
34
34
  * @see {@link createMetadata}
35
35
  * @see {@link StageParams}
36
- * @see {@link https://meocord.dev/docs/4.1/guards | Guards}
36
+ * @see {@link https://meocord.dev/docs/4.2/guards | Guards}
37
37
  */ class ExecutionContext {
38
38
  /**
39
39
  * The response state of the interaction being handled, the same one `respond(interaction)` returns,
@@ -31,7 +31,7 @@ import 'reflect-metadata';
31
31
  *
32
32
  * @group Utilities
33
33
  * @see {@link ExecutionContext}
34
- * @see {@link https://meocord.dev/docs/4.1/guards | Guards}
34
+ * @see {@link https://meocord.dev/docs/4.2/guards | Guards}
35
35
  */ function createMetadata(description) {
36
36
  const key = Symbol(description);
37
37
  const decorator = (value)=>function(target, propertyKey) {
@@ -14,6 +14,10 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
14
14
  decodeSegment(value)
15
15
  ]));
16
16
  }
17
+ /** The segment types whose value is text, which `build` takes only as a string. */ const TEXT_TYPES = new Set([
18
+ 'snowflake',
19
+ 'uuid'
20
+ ]);
17
21
  /**
18
22
  * Makes a typed route from a customId pattern, so one declaration serves the handler and the ids that reach it.
19
23
  *
@@ -48,7 +52,7 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
48
52
  *
49
53
  * @group Utilities
50
54
  * @see {@link Route}
51
- * @see {@link https://meocord.dev/docs/4.1/components | Buttons, selects and modals}
55
+ * @see {@link https://meocord.dev/docs/4.2/components | Buttons, selects and modals}
52
56
  */ function route(pattern) {
53
57
  const { params, types } = createRegexFromPattern(pattern);
54
58
  const names = new Set(params);
@@ -60,6 +64,11 @@ const PLACEHOLDER = /\{(\w+)(?::([^}/]*))?}/g;
60
64
  const value = Object.hasOwn(values, name) ? values[name] : undefined;
61
65
  const type = Object.hasOwn(types, name) ? types[name] : undefined;
62
66
  if (value === undefined || value === null) throw new TypeError(`route('${pattern}').build() needs a value for ${placeholder}.`);
67
+ // A snowflake's or uuid's value is its text: a number for a snowflake may have lost digits before it got here
68
+ if (type && TEXT_TYPES.has(type) && typeof value !== 'string') {
69
+ const lost = type === 'snowflake' ? ' A number may have lost digits already: pass the ID as text, such as user.id.' : '';
70
+ throw new TypeError(`route('${pattern}').build() got the ${typeof value} ${String(value)} for ${placeholder}, which takes its value as a string.${lost}`);
71
+ }
63
72
  const text = String(value);
64
73
  if (text === '') throw new TypeError(`route('${pattern}').build() got an empty {${name}}, which no customId segment can hold.`);
65
74
  // A typed segment must read back as the value it was built from, or the route could never match it
@@ -58,7 +58,7 @@ import { interactionLocale, messageLocale, textOfIssue, renderText } from './meo
58
58
  * @group Utilities
59
59
  * @category Localisation
60
60
  * @see {@link createTranslator}
61
- * @see {@link https://meocord.dev/docs/4.1/localisation | Localisation}
61
+ * @see {@link https://meocord.dev/docs/4.2/localisation | Localisation}
62
62
  */ function translateError(error, t, target) {
63
63
  const locale = target instanceof BaseInteraction ? interactionLocale(target) : target instanceof Message ? messageLocale(target) : target;
64
64
  return errorText(error, t, locale);
@@ -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,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 };