Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion content/4.1/cooldown-stores.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ the one you have. With node-redis:
## How it works

A store is a [service](guide:services) that extends `CooldownStore`. `@Cooldown` calls its `consumeMany(entries)`
once per call, with every stacked cooldown. Three things make a store correct:
once per call, with every stacked cooldown the call doesn't bypass. With `messages.dmOnCooldown`, a refused message
command's notice is counted in the store too, under the refusing key followed by `:notice:`. Three things make a store
correct:

- **One step.** The check and the record happen together, so two calls at the limit can't both pass.
- **One clock.** Processes on several hosts count by the database's clock, not each host's own.
Expand Down
25 changes: 14 additions & 11 deletions content/4.1/cooldowns.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,13 @@ interaction gets it privately. A message command's refusal is skipped, since
a reply in the channel can't be private, unless the app turns on
[`dmOnCooldown`](guide:message-commands#telling-the-author-privately), which tells the author once per wait.

`@Cooldown` works on interaction and message handlers. On a controller, it applies to each of its handlers apart, so
a controller's `uses: 3` gives every handler three.
`@Cooldown` works on command, component and message handlers; on an autocomplete, reaction or event handler it stops
the bot at startup. On a controller, it applies to each of its command, component and message handlers apart, so a
controller's `uses: 3` gives every one of them three.

A message command whose params name members, users, roles or channels is checked sooner too. Before they're fetched from
Discord, the call is checked, without being counted, against those of its cooldowns that have no `by`, so a call on
cooldown costs no requests.
A message command that must fetch a member, user or channel its params name, or a value of one of the app's own types,
is checked sooner too. Before anything is fetched from Discord, the call is checked, without being counted, against
those of its cooldowns that have no `by`, so a call on cooldown costs no requests.

## Options

Expand Down Expand Up @@ -101,17 +102,19 @@ it, such as `({ account }) => account.uid`.
- The value becomes part of the key the store counts under, encoded so a value holding `:` can't count under another's
key. `inspectHandler(...).cooldowns` reports `by: true` for a cooldown that has one.

The guard runs first, so a stranger pressing someone else's button is refused without spending the owner's
check-in:
The guard runs before the cooldown, so a stranger pressing someone else's button is refused before anything is
counted, and the owner, counted under their own id, can still check in:

::example{file="controllers/button/check-in.button.controller.spec.ts" region="spec"}

## Answering a refused call

A call a cooldown refuses is answered privately with `meocord.cooldown.until`: "Slow down: try again {when}.", where
`{when}` is the time the wait ends as a Discord timestamp, `<t:…:R>`. Discord words it in the reader's language ("in 5
minutes", "in 23 hours") and counts it down. The rest of the text is in the user's language where the app
[translates MeoCord's texts](guide:localisation).
An interaction a cooldown refuses is answered privately with `meocord.cooldown.until`: "Slow down: try again {when}.",
where `{when}` is the time the wait ends as a Discord timestamp, `<t:…:R>`. Discord words it in the reader's language
("in 5 minutes", "in 23 hours") and counts it down. The rest of the text is in the user's language where the app
[translates MeoCord's texts](guide:localisation). With `dmOnCooldown`, the author of a command sent in a server gets the
same wait by DM, in `meocord.dm.cooldown`, which names the command, channel and server; a command sent in a DM is
answered there with `meocord.cooldown.until`.

To answer another way, catch `CooldownError` in an [exception filter](guide:exception-filters).
[`error.retryAt`](api:responses/CooldownError#retryAt) is the `Date` the next call is allowed, and
Expand Down
7 changes: 4 additions & 3 deletions content/4.1/i18n-bot.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ The app gives MeoCord the translator, which is what makes its own texts follow t
to an interaction are in the user's language. A second announcement within the minute is refused in the author's
language, such as "Pelan-pelan: coba lagi {when}." under the title "Ups!", where `{when}` is a Discord timestamp each
member's app shows in their own language and counts down.
- **Checked when it compiles.** The keys of the `meocord` group and their `{params}` are those of
[`MeoCordMessages`](api:types/MeoCordMessages), so a misspelt key or a param MeoCord doesn't pass fails to
compile.
- **Checked when it compiles.** The keys of the `meocord` group are those of
[`MeoCordMessages`](api:types/MeoCordMessages), so a misspelt key fails to compile. The Indonesian catalog is a
plain variable, whose messages TypeScript types as `string`, so a `{param}` MeoCord doesn't pass is caught by
`expectCompleteCatalog` in the test below; written inline or with `as const`, it fails to compile too.
- **What a language leaves out.** Each text is looked up on its own, so a line the Indonesian catalog left out would
stay in MeoCord's English. This catalog translates all of them.

Expand Down
16 changes: 10 additions & 6 deletions content/4.1/invoke-and-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,11 @@ store, a validation or a `UserError`. Both wait for the module's [observers](gui
## Running a handler with invoke

Pass the arguments dispatch would: the interaction, message or reaction, then the handler's params. Passed alone, an
interaction gets its params built as dispatch builds them: a command's options, or a component's `customId` params
with a modal's fields or a select menu's choices.
interaction gets its params built as dispatch builds them: a command's or an autocomplete's options, or a component's
`customId` params with a modal's fields or a select menu's choices. A message passed alone gets what its
`@MessageHandler` pattern captures after the app's prefix, the handler's own prefix, or a mention, typed as dispatch
types it; a word that isn't of its type, or a missing param, goes through the handler's filters as a
`MessageUsageError`.

`invoke` resolves to `{ ran }`. `ran` is `false` when a guard stopped the call or an interceptor skipped the handler,
and `error` is set when a filter handled one. A guard that returns `false` stops the call with no answer:
Expand All @@ -76,9 +79,10 @@ A guard that throws `GuardDeniedError`, a handler that throws `UserError`, and a
`await expect(module.invoke(...)).rejects.toThrow(GuardDeniedError)`.

The interaction must be one dispatch routes to the handler, ranking every handler of the module as the bot does. A
`customId` another handler's pattern takes first rejects, naming the handler that runs, and so does one no pattern
takes, or a command the handler doesn't handle, so a typo in a test doesn't pass silently. A handler declared under two
patterns gets the params of the one dispatch picks. A mock built without a `customId` or command name isn't checked.
`customId` another handler's pattern takes first, or a command another handler takes by its subcommand path, rejects,
naming the handler that runs, and so does one no pattern takes, or a command the handler doesn't handle, so a typo in a
test doesn't pass silently. A handler declared under two patterns gets the params of the one dispatch picks. A mock
built without a `customId` or command name isn't checked.

## Sending input with dispatch

Expand Down Expand Up @@ -129,7 +133,7 @@ For messages and reactions, read the mock's own methods, such as `message.reply`

To send a gateway event to the module's `@On` and `@Once` handlers, use `module.emit(event, ...args)`. It resolves to
`{ ran }`, how many handlers ran, and once every handler has settled, rejects if any threw: with that error, or an
`AggregateError` naming each.
`AggregateError` holding each in its `errors`.

To check what a handler is set up with, without running it, use [`inspectHandler`](api:testing/inspectHandler). It
lists the guards, interceptors, filters and cooldowns dispatch applies, in order, and reads the handler's metadata
Expand Down
13 changes: 7 additions & 6 deletions content/4.1/lifecycle-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ For something to do in response to Discord, use [gateway events](guide:gateway-e

## How it works

Every controller and service the app binds gets hooks: those listed in `@MeoCord({ controllers, services })`,
everything they depend on, and what `@MeoCord({ providers })` supplies. [Observers](guide:observers) get them
too. Guards are created per call and get none.
Every controller and service the app binds gets hooks: those listed in `@MeoCord({ controllers, services })`, everything
they depend on, what `@MeoCord({ providers })` supplies, the app's cooldown store and its `themeFor` class.
[Observers](guide:observers) get them too. Guards, interceptors and filters get none, unless the app also binds it:
listed in `services` or `providers`, or injected by a class that gets hooks.

## onReady

Expand All @@ -41,9 +42,9 @@ process should do one-off work: `true` for a bot in one process, and with
[process sharding](guide:sharding) only in the process running shard 0.

The hooks run one at a time, each class after the classes it injects. Classes with no dependency between them run in
declaration order: the app's cooldown store first, then the `providers`, the `services`, the `controllers` and the
observers. Command registration runs alongside and never delays them. A hook still running after 10 seconds is named in
a warning, and the hooks after it wait for it.
declaration order: the app's cooldown store first, then the `providers`, the `services`, the `themeFor` class, the
`controllers` and the observers. Command registration runs alongside and never delays them. A hook still running after
10 seconds is named in a warning, and the hooks after it wait for it.

## onShutdown

Expand Down
13 changes: 7 additions & 6 deletions content/4.1/localisation.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,12 +208,13 @@ id: meocord.usage.heading takes no {command}: MeoCord's English is "Usage: {usag
typed from the message text, which TypeScript keeps only for a literal; a catalog that has lost it is refused
with a compile error saying so. Other languages may be plain objects, or JSON; their parameters are then checked
only by [`expectCompleteCatalog`](#testing-a-catalog).
- **Discord limits command names to 32 lowercase characters, and descriptions to 100.** A builder handed a longer one
fails when the handler's `@Command` builds it, naming the handler, the builder and the command. A raw command body
whose localised names or descriptions break them is caught at registration instead: nothing is registered, the error
lists each field, and the bot stays up.
- **Injecting `Translator` needs [`@MeoCord({ i18n })`](api:decorators/MeoCord#i18n).** Without it, the bot stops at
startup with a message saying what to pass.
- **Discord limits command and option names to 32 characters, lowercase for slash commands, and descriptions to 100.** A
builder handed a longer one fails when the handler's `@Command` builds it, naming the handler, the builder and the
command. A raw command body whose localised names or descriptions break them is caught at registration instead:
nothing is registered, the error lists each field, and the bot stays up.
- **Injecting `Translator` needs [`@MeoCord({ i18n })`](api:decorators/MeoCord#i18n).** Without it, any class that
injects it, a guard, interceptor, filter, pipe or presenter included, stops the bot at startup, naming the class and
saying what to pass.
- **`labelKey` needs `@MeoCord({ i18n })`, and a message in the default catalog.** `@MeoCord` refuses one
without either, where the app is declared.

Expand Down
14 changes: 8 additions & 6 deletions content/4.1/mocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ the real resolver finds them:
as a role, or a role read as a user or member, is `null`, or that error when asked with `required: true`, since the
option may be a mentionable one.
- A missing option asked with `required: true` throws `Required option "x" not found.`, and `getSubcommand()` throws
when there's none, unless given `false`, as in discord.js.
when there's none, unless given `false`, as in discord.js. So does `getChannel()` given `channelTypes`, for a channel
of another type, and `getFocused()` when no option is `focused`.
- `subcommandGroup`, `subcommand` and `focused` are reserved names: the last names the option an autocomplete is
typing.

Expand All @@ -121,10 +122,11 @@ test build. A file upload field takes an array of `Attachment`s: `createModalFie

## Messages, servers and the rest

- **`createMockMessage()`** mocks a message that tracks whether it was deleted: `delete()`, `edit()`, `reply()` and the
rest throw once it is. It takes an `id`, `content`, `components`, `embeds` and `flags`, and builders or JSON for
`components` and `embeds`. What the content mentions is cached as the gateway delivers it: a `<@id>` in the client's
`users.cache`, and in a server in `guild.members.cache`; a `<@&id>` role and a `<#id>` channel in their caches too.
- **`createMockMessage()`** mocks a message that tracks whether it was deleted: `delete()`, `edit()`, `reply()`,
`react()`, `pin()` and `unpin()` throw once it is. It takes an `id`, `content`, `components`, `embeds` and `flags`,
and builders or JSON for `components` and `embeds`. What the content mentions is cached as the gateway delivers it: a
`<@id>` in the client's `users.cache`, and in a server in `guild.members.cache`; a `<@&id>` role and a `<#id>` channel
in their caches too.
- **`author`** sends a message as a user you give, such as one from `createMockUser()`, or `client.user` for one the bot
sent. It's cached on the client, and in a server the message's `member` is the guild's cached member for that user,
made and cached when there's none. Every message from that author in one `guild` you give has the same member, and so
Expand Down Expand Up @@ -238,7 +240,7 @@ with its `error`, without counting it as sent:
- **A MeoCord mock you configure once is reset after the first test.** Set return values in the test that relies on
them, or in `beforeEach`. A `vi.fn()` of your own only has its calls cleared.
- **A command's options aren't there by default.** A mock `ChatInputCommandInteraction` has no options until you
assign `interaction.options = createChatInputOptions({ … })`.
give `options: createChatInputOptions({ … })` in its overrides, or assign it afterwards.

## Next steps

Expand Down
3 changes: 2 additions & 1 deletion content/4.1/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ or a query never reaches Discord. Keep it that way in your own [exception filter
messages: say what the member can do about it, not how the bot failed.

Answer with anything personal privately, with `flags: MessageFlags.Ephemeral`. Log IDs rather than message content, and
never log the config, which holds the token.
never log the config, which holds the token. MeoCord's `Logger` prints the token as `[redacted]` wherever it appears;
`console.log` does not.

A command that's expensive, or that posts where others see it, takes a [cooldown](guide:cooldowns). `per: 'guild'`
limits a whole server, for a command that costs the bot the same whoever runs it.
Expand Down
22 changes: 18 additions & 4 deletions content/4.1/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ typed with what it provides, and `@Inject(token)` asks for it:

::example{file="services/weather/weather.source.ts" region="token"}

An abstract class is a token and a type at once, so a class that injects it needs no decorator:
An abstract class is a token and a type at once, so a class that injects it needs no `@Inject`: the parameter's type
names it.

::example{file="services/weather/weather.source.ts" region="source"}

Expand Down Expand Up @@ -108,8 +109,9 @@ Guards, interceptors and exception filters inject services the same way:
- **A guard** is created for each call, so it may also inject `ExecutionContext`.
- **An interceptor or a filter** is one instance shared across calls, like a service. It holds no per-call state
and can't inject `ExecutionContext`; it receives the context as an argument instead.
- **A factory provider** runs once, so its `inject` can't list `ExecutionContext` either: MeoCord refuses it as the
app is created, since its value would keep the first call's context for every later one.
- **A service, a provided class or a factory provider** is made once, so none of them can inject `ExecutionContext`,
or list it in a factory's `inject`: MeoCord refuses it as the app is created, since it would keep the first call's
context for every later one.

## Testing a service

Expand All @@ -136,7 +138,19 @@ fails when the module compiles, naming both:
an `import type`. Move what they both need into a third service, or inject the parameter with @Inject(token).
```

Move what both need into a third service. `meocord/eslint` warns about import cycles as you write them.
Move what both need into a third service. `meocord/eslint` warns about import cycles as you write them, in a
project with `eslint-import-resolver-typescript`; see [Import cycles](guide:eslint#import-cycles).

- **A class without a decorator can't be injected if its constructor takes parameters.** With no decorator on the class
or on a parameter, TypeScript records none of their types, so the bot stops, naming the class:

```text
Notes: its constructor takes parameters, but Notes has no decorator, so TypeScript recorded none of their types and
it cannot be created. Decorate it with @Service(), or give a class from a package a provider in @MeoCord({ providers }).
```

A class from a package gets a provider instead. `Logger` and errors such as `UserError` aren't injected at all: create
them with `new`.

- **A factory that throws stops the bot before login,** with the token and the error. So does a token provided
twice, or one a class injects that nothing provides.
Expand Down
12 changes: 8 additions & 4 deletions content/4.1/testing-recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ its own, so `respond()` fills it from the theme, and the test reads the colour b

A testing module runs each call in its theme as the bot does: MeoCord's defaults, the app's theme, each `@UseTheme`,
then what `themeFor` looks up for the call's server and user. Guards and cooldowns run in the same pipeline, in the
bot's order, and each module counts cooldowns in a store of its own.
bot's order. Each module counts cooldowns in a store of its own: a fresh `MemoryCooldownStore`, or a new instance of the
app's `cooldownStore`, which shares its counts when it keeps them outside the process, as a Redis store does. A test
that provides `CooldownStore` itself gets that store instead, and a `useValue` provider is one instance, shared by every
module given it.

The helpers here change one of those inputs for one test, and leave the rest as the bot has them.

Expand Down Expand Up @@ -102,8 +105,8 @@ With `{ app }`, the app's global guards come first. Through `invoke`, a guard th

## Cooldowns

Each testing module counts in a fresh store, so the second call within the window is refused and a different user is
let in:
Each testing module here counts in a fresh store, so the second call within the window is refused and a different user
is let in:

::example{file="controllers/slash/daily.slash.controller.spec.ts" region="spec"}

Expand All @@ -121,7 +124,8 @@ What a member sees when something goes wrong deserves a test as much as the happ
- **An error no filter handles** rejects `invoke`, so `await expect(...).rejects.toThrow(...)` checks it. Through
`dispatch`, the member gets the fallback's answer first, and `getResponse` shows it.
- **An error a filter handled** resolves, with `error` set, and `getResponse` shows the filter's answer.
- **A `UserError`** is the member's own outcome: `dispatch` answers it privately and resolves with it.
- **A `UserError`** is the member's own outcome: `dispatch` resolves with it, after answering an interaction privately,
or replying to a message in its channel.
- **Discord's own errors** come from a mock that rejects with
[`createDiscordError(code)`](api:testing/createDiscordError). Here the author has closed their DMs, and the review
must be recorded anyway:
Expand Down
Loading
Loading