meocord 4.1.0-beta.3 → 4.1.0-beta.4
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 +56 -0
- package/README.md +362 -31
- package/dist/cjs/_shared/{cooldown-store-DJ7s2HYv.cjs → cooldown-store-CWKSQ7_1.cjs} +20 -10
- package/dist/cjs/_shared/explained-error-DxJDCxm7.cjs +196 -0
- package/dist/cjs/_shared/{providers-CHJHjNb9.cjs → providers-GXcy9wT9.cjs} +151 -34
- package/dist/cjs/_shared/{shard-context-_UhaAa3O.cjs → shard-context-DK3sAxsb.cjs} +376 -15
- package/dist/cjs/_shared/{translator-DEV2jyFH.cjs → translator-BqSfAkoS.cjs} +129 -0
- package/dist/cjs/common/index.cjs +6 -3
- package/dist/cjs/core/index.cjs +89 -113
- package/dist/cjs/decorator/index.cjs +69 -29
- package/dist/cjs/testing/index.cjs +205 -32
- package/dist/esm/bin/app-template/src/controllers/message/sample.message.controller.ts.template +0 -1
- package/dist/esm/bin/builder-template/controller/message.controller.template +0 -1
- package/dist/esm/bin/builder-template/observer.spec.template +39 -0
- package/dist/esm/bin/builder-template/observer.template +15 -0
- package/dist/esm/bin/generator.js +13 -0
- package/dist/esm/bin/helper/filter-generator.helper.js +2 -0
- package/dist/esm/bin/helper/guard-generator.helper.js +2 -0
- package/dist/esm/bin/helper/interceptor-generator.helper.js +2 -0
- package/dist/esm/bin/helper/observer-generator.helper.js +47 -0
- package/dist/esm/bin/helper/pipe-generator.helper.js +2 -0
- package/dist/esm/bin/helper/service-generator.helper.js +2 -0
- package/dist/esm/bin/meocord.js +2 -0
- package/dist/esm/build/rsbuild-config.js +9 -4
- package/dist/esm/build/runnable-bundle.js +91 -0
- package/dist/esm/common/cooldown-store.js +9 -8
- package/dist/esm/common/execution-context.js +11 -2
- package/dist/esm/common/index.js +2 -0
- package/dist/esm/common/redis-cooldown-store.js +130 -0
- package/dist/esm/common/sharded-cooldown-store.js +105 -0
- package/dist/esm/core/cooldown-runner.js +17 -4
- package/dist/esm/core/guard-runner.js +12 -1
- package/dist/esm/core/handler-pipeline.js +94 -11
- package/dist/esm/core/handler-registry.js +2 -2
- package/dist/esm/core/input-runner.js +4 -3
- package/dist/esm/core/meocord-factory.js +14 -5
- package/dist/esm/core/meocord.app.js +66 -40
- package/dist/esm/core/message-routes.js +275 -0
- package/dist/esm/core/observer-runner.js +97 -0
- package/dist/esm/core/route-specificity.js +9 -0
- package/dist/esm/core/shard-context.js +2 -0
- package/dist/esm/core/shard-manager.js +5 -0
- package/dist/esm/core/shard-messages.js +1 -1
- package/dist/esm/core/stage-scope.js +1 -1
- package/dist/esm/decorator/app.decorator.js +23 -0
- package/dist/esm/decorator/controller.decorator.js +12 -24
- package/dist/esm/decorator/cooldown.decorator.js +5 -27
- package/dist/esm/decorator/index.js +1 -0
- package/dist/esm/decorator/observer.decorator.js +45 -0
- package/dist/esm/package.json.js +1 -1
- package/dist/esm/testing/cooldown-store-suite.js +112 -0
- package/dist/esm/testing/execution-context.js +11 -3
- package/dist/esm/testing/index.js +1 -0
- package/dist/esm/testing/inspect-handler.js +9 -4
- package/dist/esm/testing/meocord-testing-module.js +39 -10
- package/dist/esm/testing/mock-interaction.js +4 -2
- package/dist/esm/testing/routing.js +33 -10
- package/dist/esm/util/generator-cli.util.js +2 -0
- package/dist/types/common/index.d.cts +127 -4
- package/dist/types/common/index.d.ts +127 -4
- package/dist/types/core/index.d.cts +1 -1
- package/dist/types/core/index.d.ts +1 -1
- package/dist/types/decorator/index.d.cts +107 -14
- package/dist/types/decorator/index.d.ts +107 -14
- package/dist/types/{errors-C6xmj0MA.d.cts → errors-CPmW5Jp6.d.cts} +34 -2
- package/dist/types/{errors-C6xmj0MA.d.ts → errors-CPmW5Jp6.d.ts} +34 -2
- package/dist/types/interface/index.d.cts +138 -4
- package/dist/types/interface/index.d.ts +138 -4
- package/dist/types/testing/index.d.cts +100 -22
- package/dist/types/testing/index.d.ts +100 -22
- package/dist/types/{translator-DeDoBQH7.d.ts → translator-CiasEmUn.d.cts} +67 -2
- package/dist/types/{translator-DeDoBQH7.d.cts → translator-CiasEmUn.d.ts} +67 -2
- package/package.json +3 -1
- package/dist/cjs/_shared/explained-error-dAMLbuqv.cjs +0 -32
- package/dist/types/cooldown-store-D1_CYKwv.d.cts +0 -66
- package/dist/types/cooldown-store-D1_CYKwv.d.ts +0 -66
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
# meocord
|
|
2
2
|
|
|
3
|
+
## 4.1.0-beta.4
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#137](https://github.com/meocord/meocord/pull/137) [`c778561`](https://github.com/meocord/meocord/commit/c778561f583a1246570881c56348c3b850bc8330) Thanks [@l7aromeo](https://github.com/l7aromeo)! - `@Cooldown` takes `by`, a function of the call that counts calls apart by a value, such as the account a button acts on: `@Cooldown({ seconds: 3600, by: (_context, { uid }: { uid: string }) => uid })` lets a user check each of their accounts in once an hour, where before the first check-in blocked the rest. `by` receives the handler's params as the handler does, after validation and pipes, and the handler's params are checked against the ones `by` declares at compile time. With `per: 'global'`, the limit is per resource across every user. Returning `undefined` counts as before; an error `by` throws goes to the exception filters, and nothing is counted. `inspectHandler(...).cooldowns` now reports `by` alongside `bypass`.
|
|
8
|
+
|
|
9
|
+
- [#143](https://github.com/meocord/meocord/pull/143) [`add425d`](https://github.com/meocord/meocord/commit/add425d6d37171828d23fb8d6e07532eec349d7f) Thanks [@l7aromeo](https://github.com/l7aromeo)! - `testCooldownStore` in `meocord/testing` checks a `CooldownStore` you write yourself, over Postgres, SQLite, MongoDB or anything else, against the behaviour `MemoryCooldownStore` defines. It registers its cases with your test framework's `describe`, `it` and `expect`, so it runs under Vitest or Jest:
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
import { testCooldownStore } from 'meocord/testing'
|
|
13
|
+
|
|
14
|
+
testCooldownStore('PostgresCooldownStore', () => new PostgresCooldownStore(sql), { describe, it, expect })
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
It covers calls within a window, a sliding window, `retryAfterMs` counted from the oldest call still in the window, each key counted on its own, calls in the same millisecond kept distinct, and several concurrent calls at the limit where exactly one passes. It uses real time with short windows and takes a few seconds. The README's [Store recipes](https://github.com/meocord/meocord/blob/main/README.md#store-recipes) show stores for Postgres, SQLite and MongoDB.
|
|
18
|
+
|
|
19
|
+
- [#140](https://github.com/meocord/meocord/pull/140) [`28f0f0a`](https://github.com/meocord/meocord/commit/28f0f0ae8bd660f3dccc332d87f657b1a069c49b) Thanks [@l7aromeo](https://github.com/l7aromeo)! - `ExecutionContext.getHandlerParams<P>()` returns the handler's params, its second argument, to any stage: a command's options, a component's customId params, a modal's fields or a message pattern's params. A guard sees them raw, an interceptor raw before `next.handle()` and validated and piped after it, and a filter as they were when the error was thrown. It returns a patterned `@MessageHandler`'s params too, and is `undefined` for message listeners, reaction and event handlers, and a call no handler was reached for. It is separate from `getParams()`, which stays the running stage's own `{ provide, params }`. `createExecutionContext` takes `handlerParams` for unit tests. `getArgs()` now returns the arguments as they stand too, so after validation and pipes its second argument is the validated and piped params, the same value `getHandlerParams()` returns; in 4.1.0-beta.1 to beta.3 it kept returning the raw arguments.
|
|
20
|
+
|
|
21
|
+
- [#148](https://github.com/meocord/meocord/pull/148) [`abf9fd8`](https://github.com/meocord/meocord/commit/abf9fd8ba1769ac1cdd2f0c9b1dd1df6de81ff5e) Thanks [@l7aromeo](https://github.com/l7aromeo)! - `@MessageHandler` takes patterns with params, using the `{name}` syntax of customId routes: `@MessageHandler('roll {sides} {note...?}')` gives the handler `{ sides, note }` as its second argument. `{name}` is one word, and words in quotes count as one; `{name...}` takes the rest of the message as typed; `{name?}` and `{name...?}` are optional; the last three come only at the end. `@Validate`, pipes, `@Cooldown({ by })` and `ExecutionContext.getHandlerParams()` see a patterned message handler's params as they do a component's. `@MeoCord({ messages: { prefix, mention, caseSensitive } })` sets the prefix, a string, a list or a function of the message, accepts a mention of the bot when `mention` is on, and matches the prefix and literal words in any case unless `caseSensitive` is set; a handler overrides them with `@MessageHandler('ping', { prefix: false | '?' | ['?', '??'], caseSensitive })`. Only the most specific matching pattern runs, across every controller: more literal words first, then a fixed number of words before a rest, then fewer params. `@MessageHandler()` without a pattern still runs for every message. A pattern that cannot be read, and two that match the same messages, stop the bot at startup. In `meocord/testing`, `resolveRoute(App, { content })` resolves a message, `invoke(Controller, 'method', message)` passes the params the pattern captures, `inspectHandler(...).pattern` reports the pattern, and `createMockMessage()` comes from a user rather than a bot.
|
|
22
|
+
|
|
23
|
+
A 4.0 keyword now matches in any case and word by word, only the most specific of two matching patterns runs, two handlers with the same keyword stop the bot at startup, and a configured prefix applies to existing keywords unless they set `prefix: false`. See [Message keywords match in any case, and only one runs](https://github.com/meocord/meocord/blob/main/docs/MIGRATING.md#message-keywords-match-in-any-case-and-only-one-runs).
|
|
24
|
+
|
|
25
|
+
- [#147](https://github.com/meocord/meocord/pull/147) [`5a2813f`](https://github.com/meocord/meocord/commit/5a2813f72b9d8afc9e0797ee266929b98433ad5c) Thanks [@l7aromeo](https://github.com/l7aromeo)! - Observers see every call MeoCord dispatches once it has settled, for metrics and audit logs. Mark a class `@Observer()`, implement `DispatchObserver`'s `onSettled(context, result)`, and list it in `@MeoCord({ observers })`. It is told about commands, components, modals, autocomplete, message, reaction and event handlers, and interactions no handler matches. The `DispatchResult` carries an `outcome` (`'ran'`, `'denied'`, `'cooldown'`, `'invalid'`, `'error'` or `'not-found'`), `startedAt` in epoch milliseconds, a `durationMs` covering the whole call through the filters and the fallback, the guard that denied it as `deniedBy`, where an interaction's answer stood as `response` (`'replied'`, `'deferred'` or `'unanswered'`), the `error`, and whether a filter or the fallback `handled` it. An optional `onStart(context)` sees the call begin, before the guards, with the same context object `onSettled` later receives, so a `WeakMap` pairs them into a span. Observers run in the order listed, and the call never waits for them: one that is slow never delays a handler, and one that throws is logged while the rest still run. `@Observer({ types })` limits one to some kinds of call. They are services, so they inject dependencies and their `onReady` and `onShutdown` hooks run in dependency order. A message no handler matches is not reported. In `meocord/testing`, `invoke` and `emit` wait for the module's observers, the testing module takes `observers`, and `inspectHandler(...).observers` lists an app's. `npx meocord g ob <name>` generates one with its spec.
|
|
26
|
+
|
|
27
|
+
- [#144](https://github.com/meocord/meocord/pull/144) [`6798abd`](https://github.com/meocord/meocord/commit/6798abd830f3f4b62b073413ca663d48b062f4b7) Thanks [@l7aromeo](https://github.com/l7aromeo)! - `RedisCooldownStore` in `meocord/common` keeps `@Cooldown` counts on Redis, so they survive a restart and are shared by every shard and process that uses the same server, which keeps `'user'` and `'global'` cooldowns exact under process sharding. MeoCord adds no Redis dependency: give `RedisCooldownStore.using` a function that runs a script with your client, and pass what it returns to `@MeoCord({ cooldownStore })`:
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
import { RedisCooldownStore } from 'meocord/common'
|
|
31
|
+
|
|
32
|
+
// node-redis
|
|
33
|
+
cooldownStore: RedisCooldownStore.using((script, keys, args) => redis.eval(script, { keys, arguments: args }))
|
|
34
|
+
// ioredis
|
|
35
|
+
cooldownStore: RedisCooldownStore.using((script, keys, args) => redis.eval(script, keys.length, ...keys, ...args))
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
One Lua script checks and records each call as one step, timed by the server's `TIME`, with calls in the same millisecond kept apart and every key set to expire. Keys start with `meocord:cooldown:`, or `{ prefix }`; pass `{ evalsha }` to send the script by its SHA1, in full only when the server answers `NOSCRIPT`. It runs on Redis 5 and later, Valkey, KeyDB, Dragonfly and Upstash; Garnet runs Lua only in part, so check it with `testCooldownStore` first. See [Where calls are counted](https://github.com/meocord/meocord/blob/main/README.md#where-calls-are-counted).
|
|
39
|
+
|
|
40
|
+
- [#145](https://github.com/meocord/meocord/pull/145) [`8f8690c`](https://github.com/meocord/meocord/commit/8f8690c84bc4e6b2676e86842b0ae3e51129c620) Thanks [@l7aromeo](https://github.com/l7aromeo)! - `ShardedCooldownStore` in `meocord/common` makes `'user'` and `'global'` cooldowns exact under process sharding without a database. Each shard asks the shard manager, which counts every shard's calls in its memory over the IPC the shards already use:
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { ShardedCooldownStore } from 'meocord/common'
|
|
44
|
+
|
|
45
|
+
@MeoCord({ controllers: [...], clientOptions: {...}, cooldownStore: ShardedCooldownStore })
|
|
46
|
+
export default class App {}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Counts are kept while the manager runs, so a shard that restarts keeps them, but they start again when the whole bot restarts. For counts that outlive a restart, or a bot on several hosts, use `RedisCooldownStore`. If the manager does not answer within a second, a shard counts the call itself and warns once. The startup warning about per-shard cooldowns stays silent with this store, and now names both shared stores. See [Where calls are counted](https://github.com/meocord/meocord/blob/main/README.md#where-calls-are-counted).
|
|
50
|
+
|
|
51
|
+
### Patch Changes
|
|
52
|
+
|
|
53
|
+
- [#142](https://github.com/meocord/meocord/pull/142) [`400f903`](https://github.com/meocord/meocord/commit/400f903510961d7b181352586f2c693f5039eabc) Thanks [@l7aromeo](https://github.com/l7aromeo)! - A `bundleDependencies` build starts under Bun, and under any devtool.
|
|
54
|
+
|
|
55
|
+
- **Bun.** A bundled ES module that probes for CommonJS, as lodash-es does with `typeof exports`, left `module` and `exports` in the bundle's top scope. Bun then read the whole bundle as CommonJS and stopped at startup with `Cannot use import statement with CommonJS-only features`, while Node ran it. Those probes now see `undefined`, as they do in any ES module, so `bun dist/main.js` starts. CommonJS dependencies keep their own `module` and `exports`. Rebuild to pick this up; nothing else changes.
|
|
56
|
+
- **Eval devtools.** An `eval-*` devtool, set through `output.sourceMap` or `tools.rspack` in the `rsbuild` hook, is built as its non-eval equivalent (`eval-source-map` as `source-map`, plain `eval` as no source map), with a warning at build time. A module evaluated from a string cannot read `import.meta`, so with `bundleDependencies` such a bundle stopped at startup with `SyntaxError: import.meta is only valid inside modules`. To silence the warning, set `output.sourceMap.js` to the non-eval devtool yourself.
|
|
57
|
+
- Development builds emit `cheap-module-source-map` rather than `eval-source-map`. See [Smaller changes](https://github.com/meocord/meocord/blob/main/docs/MIGRATING.md#smaller-changes) in the upgrade guide.
|
|
58
|
+
|
|
3
59
|
## 4.1.0-beta.3
|
|
4
60
|
|
|
5
61
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
- [Command Parameters](#command-parameters)
|
|
36
36
|
- [Subcommands](#subcommands)
|
|
37
37
|
- [Autocomplete](#autocomplete)
|
|
38
|
+
- [Message commands](#message-commands)
|
|
39
|
+
- [Prefixes](#prefixes)
|
|
40
|
+
- [Which handler runs](#which-handler-runs)
|
|
38
41
|
- [Interaction responses](#interaction-responses)
|
|
39
42
|
- [Where the interaction happened](#where-the-interaction-happened)
|
|
40
43
|
- [Presenters](#presenters)
|
|
@@ -47,7 +50,10 @@
|
|
|
47
50
|
- [Exception filters](#exception-filters)
|
|
48
51
|
- [Validation and Pipes](#validation-and-pipes)
|
|
49
52
|
- [Cooldowns](#cooldowns)
|
|
53
|
+
- [Observers](#observers)
|
|
50
54
|
- [Where calls are counted](#where-calls-are-counted)
|
|
55
|
+
- [Checking a store](#checking-a-store)
|
|
56
|
+
- [Store recipes](#store-recipes)
|
|
51
57
|
- [Custom Decorators](#custom-decorators)
|
|
52
58
|
- [Gateway Events](#gateway-events)
|
|
53
59
|
- [Handler Discovery](#handler-discovery)
|
|
@@ -75,6 +81,7 @@
|
|
|
75
81
|
- **A request pipeline** — [Guards](#guards) decide whether a handler runs, [interceptors](#interceptors) wrap it, [validation and pipes](#validation-and-pipes) check and transform its input, [cooldowns](#cooldowns) limit how often it runs, and [exception filters](#exception-filters) decide what the user is told when something throws. Each applies to a method, a controller, or the whole bot.
|
|
76
82
|
- **Interaction responses** — `respond(interaction)` answers every interaction type correctly from any state, deferred or replied, wherever a user-installed app is used; a presenter styles MeoCord's own answers.
|
|
77
83
|
- **Cooldowns** — `@Cooldown` limits how often a handler runs, per user, server, channel or for everyone, with a pluggable store to share the count across shards.
|
|
84
|
+
- **Observers** — `@Observer` classes hear about every dispatched call once it has settled, with its outcome and duration, for metrics and audit logs, without ever delaying one.
|
|
78
85
|
- **Gateway events** — `@On` and `@Once` handle any discord.js client event on a controller or service, with typed arguments and the same pipeline. `HandlerRegistry` lists every handler for a `/help` command or generated docs.
|
|
79
86
|
- **Lifecycle hooks** — `onReady` and `onShutdown` on any controller or service, in dependency order, for schedulers, cache warm-up and clean shutdown.
|
|
80
87
|
- **Localisation** — One typed catalog per locale for command names, descriptions and replies, checked at compile time.
|
|
@@ -495,6 +502,7 @@ npx meocord start --build --prod # production build + start
|
|
|
495
502
|
| `interceptor` | `i` | an interceptor and its spec |
|
|
496
503
|
| `filter` | `f` | an exception filter and its spec |
|
|
497
504
|
| `pipe` | `pi` | a pipe and its spec |
|
|
505
|
+
| `observer` | `ob` | a dispatch observer and its spec |
|
|
498
506
|
|
|
499
507
|
#### Controllers
|
|
500
508
|
|
|
@@ -735,6 +743,89 @@ If no handler claims an option, MeoCord answers with an empty list and logs whic
|
|
|
735
743
|
|
|
736
744
|
---
|
|
737
745
|
|
|
746
|
+
## Message commands
|
|
747
|
+
|
|
748
|
+
`@MessageHandler` handles messages: every message, or those matching a pattern. A pattern uses the same `{name}` params as a component's customId, matched word by word:
|
|
749
|
+
|
|
750
|
+
```typescript
|
|
751
|
+
import { type Message } from 'discord.js'
|
|
752
|
+
import { Controller, MessageHandler } from 'meocord/decorator'
|
|
753
|
+
|
|
754
|
+
@Controller()
|
|
755
|
+
export class DiceController {
|
|
756
|
+
// !roll 20 for initiative -> { sides: '20', note: 'for initiative' }
|
|
757
|
+
@MessageHandler('roll {sides} {note...?}')
|
|
758
|
+
async roll(message: Message, { sides, note }: { sides: string; note?: string }) {
|
|
759
|
+
const result = 1 + Math.floor(Math.random() * Number(sides))
|
|
760
|
+
await message.reply(note ? `${result} (${note})` : String(result))
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Runs for every message a user sends, after any pattern it matched
|
|
764
|
+
@MessageHandler()
|
|
765
|
+
async log(message: Message) {
|
|
766
|
+
console.log(message.content)
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
| In a pattern | Matches |
|
|
772
|
+
| ------------ | --------------------------------------------------------------------------------------------------- |
|
|
773
|
+
| `roll` | The word `roll`, in any case unless `caseSensitive` is set |
|
|
774
|
+
| `{name}` | One word. Words in quotes, `"like this"` or `“like this”`, count as one, and the quotes are removed |
|
|
775
|
+
| `{name...}` | The rest of the message, as typed. Only last |
|
|
776
|
+
| `{name?}` | One word, or nothing. Only last; `{name...?}` is the optional rest |
|
|
777
|
+
|
|
778
|
+
A pattern without params, such as `'hello'`, matches exactly that message. The params arrive as the handler's second argument, so [`@Validate`](#validation-and-pipes), pipes and [`@Cooldown({ by })`](#counting-per-resource) work on them as they do on a component's, and stages read them with `getHandlerParams()`:
|
|
779
|
+
|
|
780
|
+
```typescript
|
|
781
|
+
@MessageHandler('roll {sides}')
|
|
782
|
+
@Validate(z.object({ sides: z.coerce.number().int().min(2).max(100) }))
|
|
783
|
+
async roll(message: Message, { sides }: { sides: number }) {}
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
A pattern that cannot be read — `{rest...}` before another word, a name used twice — and two handlers whose patterns match exactly the same messages stop the bot at startup, naming the handlers.
|
|
787
|
+
|
|
788
|
+
### Prefixes
|
|
789
|
+
|
|
790
|
+
Set the prefix once, for the whole app, in `@MeoCord({ messages })`:
|
|
791
|
+
|
|
792
|
+
```typescript
|
|
793
|
+
@MeoCord({
|
|
794
|
+
controllers: [DiceController],
|
|
795
|
+
clientOptions: {
|
|
796
|
+
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
|
|
797
|
+
},
|
|
798
|
+
messages: { prefix: '!', mention: true },
|
|
799
|
+
})
|
|
800
|
+
class App {}
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
- `prefix` is a string, a list such as `['!', '?']`, or a function of the message returning either, which may be async — a server's own prefix, say. Without one, a pattern matches the message as it is. The longest prefix that fits is used, and a space after it is allowed: `! roll 20` works too.
|
|
804
|
+
- `mention: true` also accepts a mention of the bot, `@Bot roll 20`, in place of the prefix.
|
|
805
|
+
- `caseSensitive: true` matches the prefix and a pattern's literal words in the case written. It is off by default. Param values always keep the case they were typed in.
|
|
806
|
+
|
|
807
|
+
A handler can set its own `prefix` and `caseSensitive`. Its prefix replaces the app's; a mention still counts. `prefix: false` matches the message as it is:
|
|
808
|
+
|
|
809
|
+
```typescript
|
|
810
|
+
@MessageHandler('ping', { prefix: ['?', '??'] }) // ?ping, ??ping, @Bot ping
|
|
811
|
+
@MessageHandler('good morning', { prefix: false }) // good morning, as typed
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
### Which handler runs
|
|
815
|
+
|
|
816
|
+
Only one patterned handler runs for a message, the most specific that matches, across every controller:
|
|
817
|
+
|
|
818
|
+
1. More literal words win: `roll 20` beats `roll {sides}`, which beats `{anything...}`.
|
|
819
|
+
2. Then a fixed number of words beats a rest: `roll {a} {b}` beats `roll {rest...}`.
|
|
820
|
+
3. Then a pattern without an optional param beats one with it, and fewer params beat more.
|
|
821
|
+
4. Patterns still equal go to the one whose first differing word is literal: `roll {x}` beats `{verb} 6`.
|
|
822
|
+
|
|
823
|
+
The table is built once at startup, so declaration order and file layout never decide it. Then every `@MessageHandler()` listener runs. Messages from bots, and empty messages, reach no handler. A prefix function that throws goes to the global [exception filters](#exception-filters), then the fallback, and the listeners still run.
|
|
824
|
+
|
|
825
|
+
To check routing in a test, `resolveRoute(App, { content: '!roll 20' })` returns the handler a message reaches, and `invoke(DiceController, 'roll', createMockMessage({ content: '!roll 20' }))` runs it with the params its pattern captures — see [Testing](#testing).
|
|
826
|
+
|
|
827
|
+
---
|
|
828
|
+
|
|
738
829
|
## Interaction responses
|
|
739
830
|
|
|
740
831
|
`respond(interaction)` from `meocord/common` is the one place an interaction is answered. It remembers where the answer stands and picks the right Discord call each time, so a handler says what to send, not how:
|
|
@@ -847,7 +938,9 @@ Every handler — a command, a component, an autocomplete, a message, a reaction
|
|
|
847
938
|
|
|
848
939
|
**Exception filters** surround all of it: an error from any stage or the handler reaches them, and one no filter handles goes to the built-in fallback. The handler, its interceptors and filters, and the fallback all answer through [`respond()`](#interaction-responses), so each sees where the others left the answer.
|
|
849
940
|
|
|
850
|
-
|
|
941
|
+
**[Observers](#observers)** frame all of it: an observer's `onStart` is called as the call begins, before `@Defer` and the guards, and its `onSettled` once the call has settled and been answered, whatever the outcome, with how it ended and how long it took. The call waits for neither. They also hear about an interaction no handler matches.
|
|
942
|
+
|
|
943
|
+
Validation and pipes apply to command, component and modal handlers, and to message handlers with a pattern, whose options, customId params, fields and pattern params they check. Cooldowns apply to those and to every message handler. An autocomplete handler, which must answer within three seconds, runs its guards and filters but no interceptors. `@Defer` applies to command, component and modal handlers only.
|
|
851
944
|
|
|
852
945
|
Guards, interceptors and filters apply at three levels, which run in this order: globally, from `@MeoCord({ guards, interceptors, filters })`; on a controller, for every handler it declares or inherits; and on a method. Cooldowns apply on a controller or a method. A stage also sees what it is running for through `ExecutionContext`, whose `getType()` is `'interaction'`, `'autocomplete'`, `'message'`, `'reaction'` or `'event'`; a guard or interceptor declared with `types` runs only for those, and a subclass inherits them unless it declares its own. A `types` list that can match nothing — empty, or `['autocomplete']` on an interceptor, since interceptors skip autocomplete — throws when the class is decorated.
|
|
853
946
|
|
|
@@ -988,6 +1081,14 @@ async ban(interaction: ChatInputCommandInteraction) { ... }
|
|
|
988
1081
|
|
|
989
1082
|
Use params for configuring one guard (`{ provide, params }`, [above](#passing-options-to-a-guard)), and `createMetadata` for facts about a handler that any guard can read. `ExecutionContext` is injected only into guards: each call gets its own, so a controller or service, which is shared across calls, cannot inject it. The context also gives the handler's arguments (`getArgs()`, `getInteraction()`, `getMessage()`, `getReaction()`), what it is handling (`getType()`), the controller and method (`getController()`, `getHandlerName()`), and the guard's own params (`getParams()`). Values declared with `SetMetadata` are read with their key: `this.context.get<string[]>('roles')`.
|
|
990
1083
|
|
|
1084
|
+
`getHandlerParams<P>()` is the handler's params, its second argument: a command's options, a component's customId params or a modal's fields. It is not `getParams()`, which is the running stage's own `{ provide, params }` configuration. The value is read as it stands when a stage asks:
|
|
1085
|
+
|
|
1086
|
+
- A guard sees the params raw.
|
|
1087
|
+
- An interceptor sees them raw before `next.handle()`, and validated and piped after it, as the handler received them.
|
|
1088
|
+
- A filter sees them as they were when the error was thrown.
|
|
1089
|
+
|
|
1090
|
+
`getArgs()` follows the same stages, so its second argument is always the value `getHandlerParams()` returns. It is `undefined` for message, reaction and event handlers, which take no params, and for a call no handler was reached for. In a unit test, `createExecutionContext(Controller, 'method', { handlerParams })` sets it.
|
|
1091
|
+
|
|
991
1092
|
In a unit test, build the context with `createExecutionContext` from `meocord/testing`:
|
|
992
1093
|
|
|
993
1094
|
```typescript
|
|
@@ -1043,7 +1144,7 @@ export class ProfileController { ... }
|
|
|
1043
1144
|
|
|
1044
1145
|
Apply them like guards: on a method, on a controller, or to every handler with `@MeoCord({ interceptors })`. Global interceptors are outermost, then the controller's, then the method's; within one decorator, the first listed is outermost. A class-level `@UseInterceptor` also covers the handlers a controller inherits.
|
|
1045
1146
|
|
|
1046
|
-
One instance of an interceptor serves every call, so it can hold a cache or counters; keep per-call state in local variables. For per-use options, pass `{ provide, params }` and read them with `context.getParams()` — they are never assigned onto the shared instance. For the same reason an interceptor cannot inject `ExecutionContext`; the bot refuses to start if one does.
|
|
1147
|
+
One instance of an interceptor serves every call, so it can hold a cache or counters; keep per-call state in local variables. For per-use options, pass `{ provide, params }` and read them with `context.getParams()` — they are never assigned onto the shared instance. The call's own input is `context.getHandlerParams()`, validated and piped once `next.handle()` has run. For the same reason an interceptor cannot inject `ExecutionContext`; the bot refuses to start if one does.
|
|
1047
1148
|
|
|
1048
1149
|
Interceptors run when a handler is dispatched, or run with [`invoke`](#running-a-handler-with-invoke) in a test. A controller method called directly runs its guards but no interceptors. Autocomplete handlers run none.
|
|
1049
1150
|
|
|
@@ -1126,11 +1227,11 @@ import { Command, Validate } from 'meocord/decorator'
|
|
|
1126
1227
|
async remind(interaction: ChatInputCommandInteraction, { minutes, note }: { minutes: number; note: string }) {}
|
|
1127
1228
|
```
|
|
1128
1229
|
|
|
1129
|
-
The input is one object: a chat command's options,
|
|
1230
|
+
The input is one object: a chat command's options, a component's customId params together with a modal's fields, or a [message pattern's](#message-commands) params — what the handler's second argument holds anyway. The handler receives the schema's output, so defaults and coercions apply, and its second parameter is type-checked against it: `{ minutes: string }` above fails to compile.
|
|
1130
1231
|
|
|
1131
1232
|
Invalid input stops the call with a `ValidationError` (from `meocord/common`) whose `issues` list each problem and where it is. The user gets a private reply with them. Schema libraries write their messages in English; an exception filter that maps issues to your own words is the place to localise them.
|
|
1132
1233
|
|
|
1133
|
-
Validation runs after guards and inside interceptors, so a timing or logging interceptor sees a failure as the handler's error. It applies to command, component and modal handlers
|
|
1234
|
+
Validation runs after guards and inside interceptors, so a timing or logging interceptor sees a failure as the handler's error. It applies to command, component and modal handlers, and to message handlers with a pattern; the bot refuses to start with `@Validate` or `@UsePipe` on a message handler without one, or on a reaction, autocomplete or event handler. A handler takes one `@Validate`; a second throws, so combine the schemas into one.
|
|
1134
1235
|
|
|
1135
1236
|
### Pipes
|
|
1136
1237
|
|
|
@@ -1181,12 +1282,13 @@ async daily(interaction: ChatInputCommandInteraction) {}
|
|
|
1181
1282
|
|
|
1182
1283
|
Stacked cooldowns are counted in the order they read, a controller's first, and a call blocked by one has already spent those above it. Put the short one first, as here: a call made 1 second after the last is refused by the 3-second cooldown before it reaches the per-minute one. Written the other way round, each such call would spend one of the 5 before being refused.
|
|
1183
1284
|
|
|
1184
|
-
| Option | Default | Description
|
|
1185
|
-
| --------- | -------- |
|
|
1186
|
-
| `seconds` | — | The window's length.
|
|
1187
|
-
| `uses` | `1` | Calls allowed within the window.
|
|
1188
|
-
| `per` | `'user'` | Whose calls count together: `'user'`, `'guild'`, `'channel'` or `'global'`. Outside a server, `'guild'` and `'channel'` count per user.
|
|
1189
|
-
| `bypass` | — | `(context) => boolean`: exempts a call without counting it, such as one from an owner.
|
|
1285
|
+
| Option | Default | Description |
|
|
1286
|
+
| --------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1287
|
+
| `seconds` | — | The window's length. |
|
|
1288
|
+
| `uses` | `1` | Calls allowed within the window. |
|
|
1289
|
+
| `per` | `'user'` | Whose calls count together: `'user'`, `'guild'`, `'channel'` or `'global'`. Outside a server, `'guild'` and `'channel'` count per user. |
|
|
1290
|
+
| `bypass` | — | `(context) => boolean`: exempts a call without counting it, such as one from an owner. |
|
|
1291
|
+
| `by` | — | `(context, params) => string \| number \| undefined`: counts calls apart by a value of the call, within the scope `per` names. See below. |
|
|
1190
1292
|
|
|
1191
1293
|
A blocked call throws `CooldownError` (from `meocord/common`, with `retryAfterMs` and `per`), which the built-in fallback answers only to the caller: "Slow down: try again in 12s." `cooldownMessage(retryAfterMs)` builds that text; an [exception filter](#exception-filters) catching `CooldownError` can say it another way, or in the user's language.
|
|
1192
1294
|
|
|
@@ -1196,33 +1298,254 @@ Cooldowns are counted under the controller's class name, so the bot refuses to s
|
|
|
1196
1298
|
|
|
1197
1299
|
For a reusable exemption, compose it: `const Limited = (seconds: number) => applyDecorators(Cooldown({ seconds, bypass: isOwner }))`.
|
|
1198
1300
|
|
|
1301
|
+
### Counting per resource
|
|
1302
|
+
|
|
1303
|
+
`per` decides whose calls count together; `by` splits that count by a value of the call, such as the account a button acts on. A user with three game accounts can then check each of them in once an hour:
|
|
1304
|
+
|
|
1305
|
+
```typescript
|
|
1306
|
+
@Command('check-in/{ownerId}/{uid}', CommandType.BUTTON)
|
|
1307
|
+
@UseGuard(OwnerGuard)
|
|
1308
|
+
@Cooldown({ seconds: 3600, by: (_context, { uid }: { uid: string }) => uid })
|
|
1309
|
+
async checkIn(interaction: ButtonInteraction, { uid }: { ownerId: string; uid: string }) {}
|
|
1310
|
+
```
|
|
1311
|
+
|
|
1312
|
+
- `by` receives the call's `ExecutionContext` and the handler's params as the handler receives them: a component's customId params, a command's options, a modal's fields, after `@Validate` and pipes. For a piped object, return a stable id from it, such as `({ account }) => account.uid`.
|
|
1313
|
+
- Declare the params `by` reads, or pass them as the type argument, `@Cooldown<{ uid: string }>({ … })`, and a key the handler does not receive, or receives as another type, fails to compile. Undeclared, they are `Record<string, unknown>`.
|
|
1314
|
+
- With `per: 'global'`, the limit is per resource across every user.
|
|
1315
|
+
- Returning `undefined` counts the call as though there were no `by`. An error `by` throws goes to the [exception filters](#exception-filters), and no cooldown on the handler counts the call.
|
|
1316
|
+
- The value is added to the key the store counts under, `Controller.method#index:per:<scope>:by:<value>`, encoded so a value holding `:` cannot count under another's key. `inspectHandler(...).cooldowns` reports `by: true` for a cooldown that has one.
|
|
1317
|
+
|
|
1199
1318
|
### Where calls are counted
|
|
1200
1319
|
|
|
1201
|
-
By default in this process's memory: one count per bot, which drops keys whose calls have all expired. With [process sharding](#sharding), each shard counts on its own, so `'user'` and `'global'` cooldowns allow more than they say — the bot warns at startup. `'guild'` and `'channel'` stay exact, since a server lives on one shard.
|
|
1320
|
+
By default in this process's memory: one count per bot, which drops keys whose calls have all expired. With [process sharding](#sharding), each shard counts on its own, so `'user'` and `'global'` cooldowns allow more than they say — the bot warns at startup, unless it binds a shared store. `'guild'` and `'channel'` stay exact, since a server lives on one shard.
|
|
1321
|
+
|
|
1322
|
+
| Store | Counts | Survives a restart | Across hosts |
|
|
1323
|
+
| ----------------------------------- | ------------------------------------------------- | -------------------------------- | -------------------- |
|
|
1324
|
+
| `MemoryCooldownStore` (the default) | In this process; per shard with process sharding | No | No |
|
|
1325
|
+
| `ShardedCooldownStore` | In the shard manager, for every shard on the host | A shard's restart, not the bot's | No |
|
|
1326
|
+
| `RedisCooldownStore` | On the Redis server | Yes | Yes |
|
|
1327
|
+
| Your own `CooldownStore` | Where it keeps them | As its database does | As its database does |
|
|
1328
|
+
|
|
1329
|
+
To keep counts across restarts, or share them between shards and processes, bind a shared store with `@MeoCord({ cooldownStore })`.
|
|
1202
1330
|
|
|
1203
|
-
|
|
1331
|
+
**Process sharding on one host.** `ShardedCooldownStore` from `meocord/common` needs no database: each shard asks the shard manager, which counts every shard's calls in its memory over the IPC the shards already use, so `'user'` and `'global'` cooldowns are exact across them.
|
|
1204
1332
|
|
|
1205
1333
|
```typescript
|
|
1206
|
-
import {
|
|
1207
|
-
import { RedisService } from '@src/services/redis.service.js'
|
|
1334
|
+
import { ShardedCooldownStore } from 'meocord/common'
|
|
1208
1335
|
|
|
1209
|
-
@
|
|
1210
|
-
export class
|
|
1211
|
-
|
|
1212
|
-
super()
|
|
1213
|
-
}
|
|
1336
|
+
@MeoCord({ controllers: [...], clientOptions: {...}, cooldownStore: ShardedCooldownStore })
|
|
1337
|
+
export default class App {}
|
|
1338
|
+
```
|
|
1214
1339
|
|
|
1215
|
-
|
|
1216
|
-
return this.redis.slidingWindow(key, uses, windowMs) // a sorted set trimmed and counted in one Lua script
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1340
|
+
The manager's counts are kept while it runs: a shard that restarts keeps them, but they start again when the whole bot restarts, as the default store's do. If the manager does not answer within a second, a shard counts the call itself and logs a warning, once. Without process sharding it counts in the one process, which is exact there too.
|
|
1219
1341
|
|
|
1220
|
-
|
|
1342
|
+
**Redis, and servers that speak its protocol.** `RedisCooldownStore` from `meocord/common` counts each key in a sorted set, trimmed, counted and added to by one Lua script, timed by the server's `TIME` so every process counts by one clock, with every key set to expire. MeoCord depends on no Redis client: give `RedisCooldownStore.using` a function that runs a script with the one you have.
|
|
1343
|
+
|
|
1344
|
+
```typescript
|
|
1345
|
+
import { RedisCooldownStore } from 'meocord/common'
|
|
1346
|
+
import { createClient } from 'redis'
|
|
1347
|
+
|
|
1348
|
+
const redis = await createClient({ url: process.env.REDIS_URL }).connect()
|
|
1349
|
+
|
|
1350
|
+
@MeoCord({
|
|
1351
|
+
controllers: [...],
|
|
1352
|
+
clientOptions: {...},
|
|
1353
|
+
cooldownStore: RedisCooldownStore.using((script, keys, args) => redis.eval(script, { keys, arguments: args })),
|
|
1354
|
+
})
|
|
1221
1355
|
export default class App {}
|
|
1222
1356
|
```
|
|
1223
1357
|
|
|
1358
|
+
With ioredis, run it as `(script, keys, args) => redis.eval(script, keys.length, ...keys, ...args)`. Keys start with `meocord:cooldown:`; pass `{ prefix }` for your own. Pass `{ evalsha }`, such as `(sha, keys, args) => redis.evalSha(sha, { keys, arguments: args })`, to send the script by its SHA1, and in full only when the server answers `NOSCRIPT`.
|
|
1359
|
+
|
|
1360
|
+
The same script runs on Redis 5 and later, Valkey, KeyDB, Dragonfly and Upstash, which runs `EVAL`. Garnet runs Lua only in part: check it with [`testCooldownStore`](#checking-a-store) before relying on it.
|
|
1361
|
+
|
|
1362
|
+
**Any other database.** Extend `CooldownStore`. It is resolved like a service, so it can inject its client, and its `consume` must check and record a call in one step, so two calls at the limit cannot both pass. See [Store recipes](#store-recipes) for Postgres, SQLite and MongoDB, and check yours with [`testCooldownStore`](#checking-a-store).
|
|
1363
|
+
|
|
1224
1364
|
In tests, each `MeoCordTestingModule` counts in a fresh in-memory store; provide `{ provide: CooldownStore, useValue }` to use another. `inspectHandler(Controller, 'method').cooldowns` lists a handler's cooldowns with their defaults.
|
|
1225
1365
|
|
|
1366
|
+
### Checking a store
|
|
1367
|
+
|
|
1368
|
+
A shared store is easy to get subtly wrong. `testCooldownStore` from `meocord/testing` runs the behaviour `MemoryCooldownStore` defines against yours, under Vitest, Jest or any runner with `describe`, `it` and `expect`:
|
|
1369
|
+
|
|
1370
|
+
```typescript
|
|
1371
|
+
import { testCooldownStore } from 'meocord/testing'
|
|
1372
|
+
import { PostgresCooldownStore } from '@src/stores/postgres-cooldown.store.js'
|
|
1373
|
+
|
|
1374
|
+
testCooldownStore('PostgresCooldownStore', () => new PostgresCooldownStore(sql), { describe, it, expect })
|
|
1375
|
+
```
|
|
1376
|
+
|
|
1377
|
+
It checks calls within a window, that the window slides rather than resetting in buckets, that `retryAfterMs` counts from the oldest call still in the window — so "try again in 12s" means the same whatever the store — that each key counts on its own, that calls in the same millisecond stay distinct, and that of several concurrent calls at the limit exactly one passes. It uses real time with short windows, so it takes a few seconds, and each case counts under keys of its own, so it can run against a database that outlives the test. What it cannot see is whether every key expires: a store should give each one an expiry, or clear keys whose calls have all left their window.
|
|
1378
|
+
|
|
1379
|
+
### Store recipes
|
|
1380
|
+
|
|
1381
|
+
Stores for other databases, each checked with `testCooldownStore`. A store is resolved like a service, so inject your client, and keep the check and the record in one step: a transaction holding a lock on the key, or a single atomic update.
|
|
1382
|
+
|
|
1383
|
+
**Postgres** ([postgres.js](https://github.com/porsager/postgres)): a row per call, counted in a transaction that holds an advisory lock on the key, with the database's clock throughout.
|
|
1384
|
+
|
|
1385
|
+
```sql
|
|
1386
|
+
CREATE TABLE cooldown_calls (key text NOT NULL, at timestamptz NOT NULL DEFAULT clock_timestamp(), id bigserial PRIMARY KEY);
|
|
1387
|
+
CREATE INDEX ON cooldown_calls (key, at);
|
|
1388
|
+
```
|
|
1389
|
+
|
|
1390
|
+
```typescript
|
|
1391
|
+
consume(key: string, { uses, windowMs }: CooldownLimit): Promise<CooldownVerdict> {
|
|
1392
|
+
return this.sql.begin(async sql => {
|
|
1393
|
+
await sql`SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0))`
|
|
1394
|
+
const window = sql`${windowMs} * interval '1 millisecond'`
|
|
1395
|
+
await sql`DELETE FROM cooldown_calls WHERE key = ${key} AND at <= clock_timestamp() - ${window}`
|
|
1396
|
+
const [{ count, retry }] = await sql`
|
|
1397
|
+
SELECT count(*)::int AS count,
|
|
1398
|
+
ceil(extract(epoch FROM min(at) + ${window} - clock_timestamp()) * 1000)::int AS retry
|
|
1399
|
+
FROM cooldown_calls WHERE key = ${key}`
|
|
1400
|
+
if (count >= uses) return { allowed: false, retryAfterMs: retry }
|
|
1401
|
+
await sql`INSERT INTO cooldown_calls (key) VALUES (${key})`
|
|
1402
|
+
return { allowed: true, retryAfterMs: 0 }
|
|
1403
|
+
})
|
|
1404
|
+
}
|
|
1405
|
+
```
|
|
1406
|
+
|
|
1407
|
+
A key's rows go when it is next used; clear the rest from time to time with `DELETE FROM cooldown_calls WHERE at < now() - interval '1 day'`, or whatever your longest window is.
|
|
1408
|
+
|
|
1409
|
+
**SQLite** ([better-sqlite3](https://github.com/WiseLibs/better-sqlite3)): the same rows in an `IMMEDIATE` transaction, which takes the write lock before reading, so two processes on one database file cannot both take the last use. SQLite shares one host, so `Date.now()` is one clock.
|
|
1410
|
+
|
|
1411
|
+
```typescript
|
|
1412
|
+
consume(key: string, { uses, windowMs }: CooldownLimit): Promise<CooldownVerdict> {
|
|
1413
|
+
const now = Date.now()
|
|
1414
|
+
const consumeOne = this.db.transaction((): CooldownVerdict => {
|
|
1415
|
+
this.db.prepare('DELETE FROM cooldown_calls WHERE key = ? AND at <= ?').run(key, now - windowMs)
|
|
1416
|
+
const { count, oldest } = this.db
|
|
1417
|
+
.prepare('SELECT count(*) AS count, min(at) AS oldest FROM cooldown_calls WHERE key = ?')
|
|
1418
|
+
.get(key) as { count: number; oldest: number | null }
|
|
1419
|
+
if (count >= uses) return { allowed: false, retryAfterMs: oldest! + windowMs - now }
|
|
1420
|
+
this.db.prepare('INSERT INTO cooldown_calls (key, at) VALUES (?, ?)').run(key, now)
|
|
1421
|
+
return { allowed: true, retryAfterMs: 0 }
|
|
1422
|
+
})
|
|
1423
|
+
return Promise.resolve(consumeOne.immediate())
|
|
1424
|
+
}
|
|
1425
|
+
```
|
|
1426
|
+
|
|
1427
|
+
with `CREATE TABLE cooldown_calls (id INTEGER PRIMARY KEY, key TEXT NOT NULL, at INTEGER NOT NULL)` and an index on `(key, at)`.
|
|
1428
|
+
|
|
1429
|
+
**MongoDB**: one document per key, trimmed, counted and appended to by a single `findOneAndUpdate` with an update pipeline, so the check and the record are one atomic write, timed by the server's `$$NOW`. A TTL index on `expiresAt` removes a key once its window has passed.
|
|
1430
|
+
|
|
1431
|
+
```typescript
|
|
1432
|
+
async consume(key: string, { uses, windowMs }: CooldownLimit): Promise<CooldownVerdict> {
|
|
1433
|
+
const id = randomUUID() // tells this call apart from one in the same millisecond
|
|
1434
|
+
const kept = { $filter: { input: { $ifNull: ['$calls', []] }, cond: { $gt: ['$$this.at', { $subtract: ['$$NOW', windowMs] }] } } }
|
|
1435
|
+
const doc = await this.cooldowns.findOneAndUpdate(
|
|
1436
|
+
{ _id: key },
|
|
1437
|
+
[
|
|
1438
|
+
{ $set: { calls: kept } },
|
|
1439
|
+
{
|
|
1440
|
+
$set: {
|
|
1441
|
+
calls: { $cond: [{ $lt: [{ $size: '$calls' }, uses] }, { $concatArrays: ['$calls', [{ at: '$$NOW', id }]] }, '$calls'] },
|
|
1442
|
+
expiresAt: { $add: ['$$NOW', windowMs] },
|
|
1443
|
+
now: '$$NOW',
|
|
1444
|
+
},
|
|
1445
|
+
},
|
|
1446
|
+
],
|
|
1447
|
+
{ upsert: true, returnDocument: 'after' },
|
|
1448
|
+
)
|
|
1449
|
+
if (doc!.calls.some((call: { id: string }) => call.id === id)) return { allowed: true, retryAfterMs: 0 }
|
|
1450
|
+
return { allowed: false, retryAfterMs: doc!.calls[0].at.getTime() + windowMs - doc!.now.getTime() }
|
|
1451
|
+
}
|
|
1452
|
+
```
|
|
1453
|
+
|
|
1454
|
+
with `db.cooldowns.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 })`.
|
|
1455
|
+
|
|
1456
|
+
---
|
|
1457
|
+
|
|
1458
|
+
## Observers
|
|
1459
|
+
|
|
1460
|
+
An observer is told about every call MeoCord dispatches, once it has settled: commands, components, modals, autocomplete, message, reaction and event handlers, and interactions no handler matches. It is where metrics and audit logs go, since no other stage sees every outcome. Guards run before anything is decided, interceptors never see a call a guard denied or autocomplete, and filters see only errors.
|
|
1461
|
+
|
|
1462
|
+
```typescript
|
|
1463
|
+
import { Observer } from 'meocord/decorator'
|
|
1464
|
+
import { type ExecutionContext } from 'meocord/common'
|
|
1465
|
+
import { type DispatchObserver, type DispatchResult } from 'meocord/interface'
|
|
1466
|
+
|
|
1467
|
+
@Observer()
|
|
1468
|
+
export class MetricsObserver implements DispatchObserver {
|
|
1469
|
+
constructor(private readonly metrics: MetricsService) {}
|
|
1470
|
+
|
|
1471
|
+
onSettled(context: ExecutionContext, { outcome, durationMs }: DispatchResult) {
|
|
1472
|
+
this.metrics.record(context.getType(), context.getHandlerName() ?? 'unrouted', outcome, durationMs)
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
@MeoCord({ controllers: [ShopController], observers: [MetricsObserver], clientOptions: { intents: [] } })
|
|
1477
|
+
class App {}
|
|
1478
|
+
```
|
|
1479
|
+
|
|
1480
|
+
`DispatchResult` holds:
|
|
1481
|
+
|
|
1482
|
+
| Field | What it is |
|
|
1483
|
+
| ------------ | ------------------------------------------------------------------------------------------------------------------------- |
|
|
1484
|
+
| `outcome` | `'ran'`, `'denied'`, `'cooldown'`, `'invalid'`, `'error'` or `'not-found'`; see below. |
|
|
1485
|
+
| `startedAt` | When dispatch received the call, in milliseconds since the Unix epoch. |
|
|
1486
|
+
| `durationMs` | From dispatch until the filters and the fallback had answered, from `performance.now()`. |
|
|
1487
|
+
| `deniedBy` | The guard class that denied the call, whether it returned `false` or threw `GuardDeniedError`. |
|
|
1488
|
+
| `response` | For an interaction, where its answer stood: `'replied'`, `'deferred'` (deferred and never followed up) or `'unanswered'`. |
|
|
1489
|
+
| `error` | The error the call ended with, when it ended with one. |
|
|
1490
|
+
| `handled` | Whether an exception filter or the built-in fallback answered the error; `false` without one. |
|
|
1491
|
+
|
|
1492
|
+
| Outcome | When |
|
|
1493
|
+
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1494
|
+
| `'ran'` | The call settled without an error. An interceptor that answers without calling `next.handle()`, from a cache for instance, counts too. |
|
|
1495
|
+
| `'denied'` | A guard returned `false` (no `error`) or threw `GuardDeniedError`. |
|
|
1496
|
+
| `'cooldown'` | A `@Cooldown` refused it with `CooldownError`. |
|
|
1497
|
+
| `'invalid'` | `@Validate` refused its input with `ValidationError`. |
|
|
1498
|
+
| `'error'` | Anything else was thrown, by the handler, a pipe, an interceptor or a guard. |
|
|
1499
|
+
| `'not-found'` | No handler matches the interaction: `CommandNotFoundError`, or an autocomplete no `@Autocomplete` claims (no `error`). |
|
|
1500
|
+
|
|
1501
|
+
`response` catches a handler that left an interaction hanging: `'deferred'` means the user still sees "thinking…".
|
|
1502
|
+
|
|
1503
|
+
What is reported: every interaction, the ones no handler matches included; every message and reaction a handler runs for; and every event handler call. A message no handler matches is not reported: messages are not commands, and most of a server's traffic would reach the observers for nothing.
|
|
1504
|
+
|
|
1505
|
+
- **Read-only.** An observer runs outside the call, and the call waits for none of its methods, so a slow observer never delays a handler. One that throws is logged through `Logger`, and the others still run.
|
|
1506
|
+
- **In order.** Observers are told one after another, in the order `observers` lists them.
|
|
1507
|
+
- **By type.** `@Observer({ types: ['interaction'] })` is told only about those calls, as `ExecutionContext.getType()` reports them. An empty list throws, and a subclass inherits the types unless it declares its own.
|
|
1508
|
+
- **A service.** One instance is resolved from the container, so an observer injects services, and its `onReady` and `onShutdown` hooks run in dependency order with the rest; an exporter flushes what it buffered in `onShutdown`, before the services it uses shut down. It cannot inject `ExecutionContext`; each call's context is passed in, and has no controller or handler for an interaction no handler matched.
|
|
1509
|
+
- **Testing.** `invoke` and `emit` wait for the module's observers before they resolve, so a test sees what they were told. The testing module takes an `app`'s observers, and `observers` of its own. `inspectHandler(Controller, 'method', { app }).observers` lists the app's.
|
|
1510
|
+
|
|
1511
|
+
Generate one with `npx meocord g ob <name>`.
|
|
1512
|
+
|
|
1513
|
+
### Tracing a call
|
|
1514
|
+
|
|
1515
|
+
`onStart` receives the call as it begins, and `onSettled` for the same call receives the same context object, so a `WeakMap` pairs them: that gives a span for every call, a denied one or one no handler matched included. An observer sees the call from outside, though: a span for work inside the handler, such as a database query nested under the command, comes from an interceptor, which runs within the call. With OpenTelemetry, use both:
|
|
1516
|
+
|
|
1517
|
+
```typescript
|
|
1518
|
+
// The call's own span, from the moment it arrives, whatever the outcome
|
|
1519
|
+
@Observer()
|
|
1520
|
+
export class CallSpanObserver implements DispatchObserver {
|
|
1521
|
+
private readonly spans = new WeakMap<ExecutionContext, Span>()
|
|
1522
|
+
|
|
1523
|
+
onStart(context: ExecutionContext) {
|
|
1524
|
+
this.spans.set(context, tracer.startSpan(`${context.getType()} ${context.getHandlerName()}`))
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
onSettled(context: ExecutionContext, { outcome }: DispatchResult) {
|
|
1528
|
+
const span = this.spans.get(context)
|
|
1529
|
+
span?.setAttribute('meocord.outcome', outcome)
|
|
1530
|
+
span?.end()
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// The handler's span, active while it runs, so the spans it starts nest under it
|
|
1535
|
+
@Interceptor()
|
|
1536
|
+
export class HandlerSpanInterceptor implements InterceptorInterface {
|
|
1537
|
+
async intercept(context: ExecutionContext, next: CallHandler) {
|
|
1538
|
+
return tracer.startActiveSpan(`handler ${context.getHandlerName()}`, async span => {
|
|
1539
|
+
try {
|
|
1540
|
+
return await next.handle()
|
|
1541
|
+
} finally {
|
|
1542
|
+
span.end()
|
|
1543
|
+
}
|
|
1544
|
+
})
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
```
|
|
1548
|
+
|
|
1226
1549
|
---
|
|
1227
1550
|
|
|
1228
1551
|
## Custom Decorators
|
|
@@ -1324,7 +1647,7 @@ export class HelpService {
|
|
|
1324
1647
|
| `component` | The customId pattern, such as `profile/{uid}` | `commandType` |
|
|
1325
1648
|
| `modal` | The customId pattern | `commandType` |
|
|
1326
1649
|
| `autocomplete` | The command path, then the option it completes | |
|
|
1327
|
-
| `message` | The
|
|
1650
|
+
| `message` | The pattern, or `undefined` for every message | |
|
|
1328
1651
|
| `reaction` | The emoji, or `undefined` for every reaction | |
|
|
1329
1652
|
| `event` | The client event | `once` |
|
|
1330
1653
|
|
|
@@ -1537,7 +1860,7 @@ const controller = module.get(GreetingSlashController)
|
|
|
1537
1860
|
|
|
1538
1861
|
### Running a handler with `invoke`
|
|
1539
1862
|
|
|
1540
|
-
`module.invoke(Controller, 'method', ...args)` runs a handler through the [pipeline](#how-a-handler-runs) dispatch runs: `@Defer`, its guards, class guards first and each once, then its interceptors around validation, pipes, cooldowns and the handler, all inside its exception filters. Guards resolve from the module, so `overrideGuard` stubs and injected `ExecutionContext` work as they do in the bot. Pass the arguments dispatch would: the interaction, message or reaction, then the handler's params. An interaction must be one dispatch could route to the handler: a customId its pattern matches, or the command or subcommand path it handles. One that could not, such as `'something/else'` for `'profile/{id}'`, rejects with a message naming both, so a typo in a test does not pass silently. A mock built without a customId or command name is not checked.
|
|
1863
|
+
`module.invoke(Controller, 'method', ...args)` runs a handler through the [pipeline](#how-a-handler-runs) dispatch runs: `@Defer`, its guards, class guards first and each once, then its interceptors around validation, pipes, cooldowns and the handler, all inside its exception filters. Guards resolve from the module, so `overrideGuard` stubs and injected `ExecutionContext` work as they do in the bot. Pass the arguments dispatch would: the interaction, message or reaction, then the handler's params. An interaction must be one dispatch could route to the handler: a customId its pattern matches, or the command or subcommand path it handles. One that could not, such as `'something/else'` for `'profile/{id}'`, rejects with a message naming both, so a typo in a test does not pass silently. A mock built without a customId or command name is not checked. A message passed alone to a [patterned `@MessageHandler`](#message-commands) is checked the same way, and the handler gets the params its pattern captures, after the prefix of the module's `app`: `invoke(DiceController, 'roll', createMockMessage({ content: '!roll 20' }))`.
|
|
1541
1864
|
|
|
1542
1865
|
```typescript
|
|
1543
1866
|
import { ButtonInteraction } from 'discord.js'
|
|
@@ -1568,7 +1891,7 @@ Pass the interaction alone and `invoke` builds the params as dispatch does: a co
|
|
|
1568
1891
|
|
|
1569
1892
|
To send a client event to the module's `@On` and `@Once` handlers, use [`emit`](#gateway-events).
|
|
1570
1893
|
|
|
1571
|
-
To check what a handler is set up with, without running it, use `inspectHandler`. It lists the guards, interceptors, filters and cooldowns dispatch applies, in order, and reads the handler's metadata as `ExecutionContext` does:
|
|
1894
|
+
To check what a handler is set up with, without running it, use `inspectHandler`. It lists the guards, interceptors, filters and cooldowns dispatch applies, in order, gives a message handler's `pattern`, and reads the handler's metadata as `ExecutionContext` does:
|
|
1572
1895
|
|
|
1573
1896
|
```typescript
|
|
1574
1897
|
import { inspectHandler } from 'meocord/testing'
|
|
@@ -1777,7 +2100,7 @@ edited.delete // → a mock fn
|
|
|
1777
2100
|
expect(msg.delete).toHaveBeenCalledTimes(1)
|
|
1778
2101
|
```
|
|
1779
2102
|
|
|
1780
|
-
Without arguments the message is empty. Give it an `id`, `content`, `components`, `embeds` and `flags` to test code that reads them, such as a button on a message whose controls `@Defer` locks. Components and embeds may be API JSON, builders or discord.js instances, and `flags` a number, flag names or a `MessageFlagsBitField`:
|
|
2103
|
+
Without arguments the message is empty, and its author is a user rather than a bot, so dispatch and `invoke` handle it. Give it an `id`, `content`, `components`, `embeds` and `flags` to test code that reads them, such as a button on a message whose controls `@Defer` locks. Components and embeds may be API JSON, builders or discord.js instances, and `flags` a number, flag names or a `MessageFlagsBitField`:
|
|
1781
2104
|
|
|
1782
2105
|
```typescript
|
|
1783
2106
|
const message = createMockMessage({
|
|
@@ -1799,8 +2122,8 @@ API JSON and builders are kept as their JSON behind `toJSON()`, which is what `r
|
|
|
1799
2122
|
<details>
|
|
1800
2123
|
<summary><b><code>resolveRoute</code> / <code>findRouteConflicts</code></b></summary>
|
|
1801
2124
|
|
|
1802
|
-
Tests which handler a component's customId reaches — the same answer dispatch
|
|
1803
|
-
controller your app registers, most specific pattern first. They read decorator metadata only, so
|
|
2125
|
+
Tests which handler a component's customId or a message's content reaches — the same answer dispatch
|
|
2126
|
+
gives, across every controller your app registers, most specific pattern first. They read decorator metadata only, so
|
|
1804
2127
|
they need no Discord client, config or container. They check routing alone: guards are not run, and
|
|
1805
2128
|
whether a controller's dependencies are bound is for `MeoCordTestingModule` to test.
|
|
1806
2129
|
|
|
@@ -1818,6 +2141,11 @@ it('routes the profile button to its handler', () => {
|
|
|
1818
2141
|
expect(route?.params).toEqual({ ownerId: '111', uid: '8000' })
|
|
1819
2142
|
})
|
|
1820
2143
|
|
|
2144
|
+
// A message, after the prefix @MeoCord({ messages }) configures
|
|
2145
|
+
it('routes !roll to the dice handler', () => {
|
|
2146
|
+
expect(resolveRoute(App, { content: '!roll 20 for luck' })?.params).toEqual({ sides: '20', note: 'for luck' })
|
|
2147
|
+
})
|
|
2148
|
+
|
|
1821
2149
|
// Patterns that can match the same customId, as a failing test rather than a startup warning.
|
|
1822
2150
|
it('has no overlapping component patterns', () => {
|
|
1823
2151
|
expect(findRouteConflicts(App)).toEqual([])
|
|
@@ -1825,7 +2153,10 @@ it('has no overlapping component patterns', () => {
|
|
|
1825
2153
|
```
|
|
1826
2154
|
|
|
1827
2155
|
`resolveRoute` returns the `controller`, the `handler` method and its name as `method`, and the
|
|
1828
|
-
`params` the pattern captured — or `undefined` when no route handles the customId.
|
|
2156
|
+
`params` the pattern captured — or `undefined` when no route handles the customId or message. A
|
|
2157
|
+
message never resolves to a `@MessageHandler()` listener, which runs for every message. For an app
|
|
2158
|
+
that reads prefixes from a function, pass the one the message has, `{ content, prefix: '?' }`, and
|
|
2159
|
+
pass `botId` for a message that mentions the bot.
|
|
1829
2160
|
|
|
1830
2161
|
</details>
|
|
1831
2162
|
|