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.
- package/CHANGELOG.md +104 -0
- package/README.md +23 -23
- package/dist/cjs/_shared/{controller.decorator-BywwkU9O.cjs → controller.decorator-CRn9kWob.cjs} +295 -87
- package/dist/cjs/_shared/{providers-BMMLp2SN.cjs → providers-ChvMqnLd.cjs} +74 -40
- package/dist/cjs/_shared/{sharded-cooldown-store-B65iaiAy.cjs → sharded-cooldown-store-BdAUTdYs.cjs} +1 -1
- package/dist/cjs/_shared/{startup-roots-lFc69AxC.cjs → startup-roots-CQo8mSP8.cjs} +158 -72
- package/dist/cjs/_shared/{translate-error-BnM8FbSH.cjs → translate-error-DhDVbT5M.cjs} +12 -3
- package/dist/cjs/common/index.cjs +4 -4
- package/dist/cjs/core/index.cjs +195 -157
- package/dist/cjs/decorator/index.cjs +66 -57
- package/dist/cjs/testing/index.cjs +736 -307
- package/dist/cli.json +1 -1
- package/dist/esm/bin/app-template/README.md.template +3 -1
- package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template +8 -4
- package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template +2 -2
- package/dist/esm/bin/app-template/src/guards/owner.guard.spec.ts.template +8 -4
- package/dist/esm/bin/app-template/src/guards/owner.guard.ts.template +1 -1
- package/dist/esm/bin/app-template/vitest.config.ts.template +1 -1
- package/dist/esm/bin/app-template/vitest.setup.ts.template +7 -3
- package/dist/esm/build/load-config.pre-entry.js +4 -1
- package/dist/esm/common/decorator.js +6 -6
- package/dist/esm/common/deprecation.js +2 -2
- package/dist/esm/common/errors.js +2 -2
- package/dist/esm/common/execution-context.js +1 -1
- package/dist/esm/common/metadata.js +1 -1
- package/dist/esm/common/route.js +10 -1
- package/dist/esm/common/translate-error.js +1 -1
- package/dist/esm/core/command-conflicts.js +34 -34
- package/dist/esm/core/component-routes.js +74 -31
- package/dist/esm/core/dispatcher.js +51 -7
- package/dist/esm/core/filter-runner.js +2 -2
- package/dist/esm/core/handler-pipeline.js +27 -14
- package/dist/esm/core/handler-registry.js +1 -1
- package/dist/esm/core/interceptor-runner.js +2 -2
- package/dist/esm/core/meocord-factory.js +196 -157
- package/dist/esm/core/message-routes.js +4 -3
- package/dist/esm/core/observer-runner.js +4 -3
- package/dist/esm/core/providers.js +38 -21
- package/dist/esm/core/route-specificity.js +4 -4
- package/dist/esm/core/scalar-types.js +15 -3
- package/dist/esm/core/shard-context.js +1 -1
- package/dist/esm/core/startup-roots.js +2 -3
- package/dist/esm/core/theme-runtime.js +2 -2
- package/dist/esm/decorator/app.decorator.js +12 -7
- package/dist/esm/decorator/command-builder.decorator.js +4 -4
- package/dist/esm/decorator/controller-class.decorator.js +9 -4
- package/dist/esm/decorator/controller.decorator.js +110 -67
- package/dist/esm/decorator/cooldown.decorator.js +3 -3
- package/dist/esm/decorator/defer.decorator.js +4 -4
- package/dist/esm/decorator/event.decorator.js +5 -5
- package/dist/esm/decorator/filter.decorator.js +7 -7
- package/dist/esm/decorator/guard.decorator.js +8 -8
- package/dist/esm/decorator/inject.decorator.js +4 -4
- package/dist/esm/decorator/interceptor.decorator.js +7 -7
- package/dist/esm/decorator/observer.decorator.js +4 -4
- package/dist/esm/decorator/service.decorator.js +4 -4
- package/dist/esm/decorator/theme.decorator.js +3 -3
- package/dist/esm/decorator/validation.decorator.js +10 -10
- package/dist/esm/package.json.js +1 -1
- package/dist/esm/testing/catalog.js +1 -1
- package/dist/esm/testing/cooldown-store-suite.js +18 -10
- package/dist/esm/testing/discord-defaults.js +60 -7
- package/dist/esm/testing/index.js +4 -2
- package/dist/esm/testing/inspect-handler.js +20 -3
- package/dist/esm/testing/meocord-testing-module.js +229 -188
- package/dist/esm/testing/mock-fn.js +80 -10
- package/dist/esm/testing/mock-interaction.js +226 -78
- package/dist/esm/testing/response.js +1 -1
- package/dist/esm/testing/routing.js +39 -14
- package/dist/esm/testing/startup-errors.js +26 -0
- package/dist/esm/testing/strict-mocks.js +45 -0
- package/dist/esm/testing/theme.js +1 -1
- package/dist/esm/util/bundle-entry.util.js +2 -1
- package/dist/esm/util/interaction.util.js +1 -1
- package/dist/esm/util/meocord-config-validation.util.js +1 -0
- package/dist/esm/util/metadata-keys.js +1 -0
- package/dist/esm/util/refusal.util.js +148 -8
- package/dist/types/common/index.d.cts +8 -8
- package/dist/types/common/index.d.ts +8 -8
- package/dist/types/core/index.d.cts +8 -6
- package/dist/types/core/index.d.ts +8 -6
- package/dist/types/decorator/index.d.cts +30 -29
- package/dist/types/decorator/index.d.ts +30 -29
- package/dist/types/{index-D7n0FBMm.d.ts → index-CdXsFgjE.d.ts} +62 -15
- package/dist/types/{index-DEP0NYrU.d.cts → index-nn5p7OoX.d.cts} +62 -15
- package/dist/types/interface/index.d.cts +1 -1
- package/dist/types/interface/index.d.ts +1 -1
- package/dist/types/{route-C-aMI7Sl.d.cts → route-BZgFoNeB.d.cts} +5 -2
- package/dist/types/{route-C-aMI7Sl.d.ts → route-BZgFoNeB.d.ts} +5 -2
- package/dist/types/testing/index.d.cts +245 -31
- package/dist/types/testing/index.d.ts +245 -31
- package/dist/types/{theme-resolvers-DedxyscT.d.ts → theme-resolvers-BcOJVzR9.d.ts} +1 -1
- package/dist/types/{theme-resolvers-COHdX5g6.d.cts → theme-resolvers-UKr3-6Ys.d.cts} +1 -1
- package/dist/types/{translator-J6tIUfdp.d.ts → translator-BViWKtLJ.d.ts} +1 -1
- package/dist/types/{translator-B4mc1lzJ.d.cts → translator-DHMkkCpg.d.cts} +1 -1
- package/package.json +3 -2
package/dist/cli.json
CHANGED
|
@@ -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`
|
|
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
|
package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.spec.ts.template
CHANGED
|
@@ -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(
|
|
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(
|
|
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:
|
|
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(
|
|
45
|
+
const interaction = clickBy(stranger, `button-with/${owner}`)
|
|
42
46
|
|
|
43
47
|
await expect(module.invoke(SampleButtonController, 'handleButtonWithId', interaction)).rejects.toThrow(
|
|
44
48
|
GuardDeniedError,
|
package/dist/esm/bin/app-template/src/controllers/button/sample.button.controller.ts.template
CHANGED
|
@@ -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:
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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) {
|
package/dist/esm/common/route.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
274
|
-
const one = problems.
|
|
275
|
-
logger.warn(`${problems.
|
|
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
|
|
279
|
-
*
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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,
|
|
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
|
-
*
|
|
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
|
|
41
|
-
return routes.sort((
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
/**
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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,
|
|
154
|
+
export { buildComponentRoutes, findComponentRouteConflicts, matchComponentRoute, readCustomId };
|